PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Modules / Form / TokenBasedSpamProtection.php

TokenBasedSpamProtection.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.14, at app/Modules/Form/TokenBasedSpamProtection.php

193 lines 7.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace FluentForm\App\Modules\Form;
3
4 defined('ABSPATH') or die;
5
6 use FluentForm\App\Helpers\Helper;
7 use FluentForm\App\Helpers\Protector;
8 use FluentForm\Framework\Helpers\ArrayHelper as Arr;
9
10 class TokenBasedSpamProtection
11 {
12
13 public function __construct($app)
14 {
15 if (!$this->isEnabled()) {
16 return;
17 }
18
19 $app->addAction('wp_ajax_fluentform_generate_protection_token', [$this, 'ajaxGenerateToken']);
20 $app->addAction('wp_ajax_nopriv_fluentform_generate_protection_token', [$this, 'ajaxGenerateToken']);
21
22 add_filter('fluentform/global_form_vars', function ($vars){
23 $vars['token_nonce'] = wp_create_nonce('fluentform_generate_token_nonce');
24 return $vars;
25 });
26
27 }
28
29 public function renderTokenField($form)
30 {
31 if (!$this->isEnabled($form->id)) {
32 return;
33 }
34
35 $fieldName = $this->getFieldName($form->id);
36 ?>
37 <input type="hidden" id="<?php echo esc_attr($fieldName); ?>" class="fluent-form-token-field" name="<?php echo esc_attr($fieldName); ?>">
38 <?php
39 }
40
41 public function ajaxGenerateToken()
42 {
43 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified on next line
44 $nonce = sanitize_text_field(Arr::get($_POST, 'nonce'));
45 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified on next line
46 $formId = (int)Arr::get($_POST,'form_id');
47
48 $nonceVerified = wp_verify_nonce($nonce, 'fluentform_generate_token_nonce');
49 if (!$formId || !$nonceVerified) {
50 wp_send_json_error([
51 'message' => __('Invalid request', 'fluentform')
52 ]);
53 }
54
55 $token = $this->generateToken($formId);
56 $response = apply_filters('fluentform/token_based_protection_response', [
57 'token' => $token
58 ], $formId);
59
60 wp_send_json_success($response);
61 }
62
63 private function generateToken($formId)
64 {
65 $timeStamp = current_time('timestamp');
66 $fieldName = $this->getFieldName($formId);
67 $data = implode('|', [$timeStamp, $formId, $fieldName]);
68
69 return apply_filters('fluentform/generated_protection_token', Protector::encrypt($data), $formId, $timeStamp);
70 }
71
72 /**
73 * SECURITY (FINDING-25): mint a token input for the conversational form so it is carried in the
74 * submission (the conversational JS forwards every extra_input) and the token check can be
75 * enforced server-side instead of being skippable via the client isFFConversational flag. This
76 * is static and self-contained to avoid the constructor's action registrations; it mirrors
77 * isEnabled() / getFieldName() / generateToken() above (same filters, same payload format, so
78 * validateToken() accepts it).
79 *
80 * @param int $formId
81 * @return array
82 */
83 public static function getConversationalTokenInput($formId)
84 {
85 $option = get_option('_fluentform_global_form_settings');
86 $enabled = 'yes' === Arr::get($option, 'misc.tokenBasedProtectionStatus');
87 $enabled = apply_filters('fluentform/token_based_spam_protection_status', $enabled, $formId);
88 if (!$enabled) {
89 return [];
90 }
91
92 $fieldName = apply_filters('fluentform/token_protection_name', '__fluent_protection_token_' . $formId, $formId);
93 $timeStamp = current_time('timestamp');
94 $data = implode('|', [$timeStamp, $formId, $fieldName]);
95 $token = apply_filters('fluentform/generated_protection_token', Protector::encrypt($data), $formId, $timeStamp);
96
97 return [$fieldName => $token];
98 }
99
100 public function verify($insertData, $requestData, $formId)
101 {
102 // SECURITY (FINDING-25): do NOT skip the check for conversational forms based on the
103 // client-supplied isFFConversational flag — that let an attacker bypass token protection by
104 // adding one parameter. The conversational renderer now injects a valid token into the
105 // submission (getConversationalTokenInput via extra_inputs), so the check is enforced for
106 // conversational and regular forms alike; only a genuinely-disabled feature is skipped.
107 if (!$this->isEnabled($formId)) {
108 return;
109 }
110
111 $fieldName = $this->getFieldName($formId);
112 $token = sanitize_text_field(Arr::get($requestData, $fieldName));
113 if (!$token || !$this->validateToken($token, $formId)) {
114 $errorMessage = apply_filters(
115 'fluentform/token_based_validation_error_message',
116 __('Suspicious activity detected. Form submission blocked', 'fluentform'),
117 $formId
118 );
119
120 $this->handleSpam($errorMessage);
121 }
122 }
123
124 private function validateToken($token, $formId)
125 {
126 try {
127
128 $decrypted = Protector::decrypt($token);
129 if (!$decrypted) {
130 return false;
131 }
132
133 $parts = explode('|', $decrypted);
134 if (count($parts) !== 3) {
135 return false;
136 }
137
138 [$timestamp, $tokenFormId, $fieldName] = $parts;
139
140 // Ensure all components are valid
141 if (!is_numeric($timestamp) || !is_numeric($tokenFormId)) {
142 return false;
143 }
144
145 $expirationTime = apply_filters('fluentform/token_expiration_time', 3600, $formId); //1 hour
146 if ($timestamp + $expirationTime < current_time('timestamp')) {
147 return false;
148 }
149
150 $isValid = (int)$tokenFormId === $formId && $fieldName === $this->getFieldName($formId);
151
152 // NOTE (FINDING-27): a per-token single-use cap was intentionally NOT implemented here.
153 // Storing one WordPress transient per minted token would create unbounded options-table
154 // writes on this high-frequency public path (tokens are cheaply minted via the public
155 // endpoint and expired transients are not proactively cleaned). The token remains a
156 // defence-in-depth anti-bot control (a bot must fetch a nonce-gated, form/field-bound,
157 // time-limited token); replay within the expiration window is the accepted LOW residual.
158
159 return apply_filters('fluentform/token_based_validation_result',
160 $isValid,
161 $timestamp,
162 $tokenFormId,
163 $formId);
164
165 } catch (\Exception $e) {
166 return false;
167 }
168 }
169
170
171 private function handleSpam($reason)
172 {
173 do_action('fluentform/spam_attempt_caught', $reason);
174
175 wp_send_json([
176 'errors' => $reason
177 ], 422);
178 }
179
180 public function isEnabled($formId = false)
181 {
182 $option = get_option('_fluentform_global_form_settings');
183 $status = 'yes' === Arr::get($option, 'misc.tokenBasedProtectionStatus');
184 return apply_filters('fluentform/token_based_spam_protection_status', $status, $formId);
185 }
186
187 private function getFieldName($formId)
188 {
189 $tokenInputName = '__fluent_protection_token_'. $formId;
190 return apply_filters('fluentform/token_protection_name', $tokenInputName, $formId);
191 }
192 }
193