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
← All changes | app/Modules/Form/TokenBasedSpamProtection.php +44 -9 6.2.36.2.14 View file →
@@ -64,24 +64,51 @@
64 64 {
65 65 $timeStamp = current_time('timestamp');
66 66 $fieldName = $this->getFieldName($formId);
67 67 $data = implode('|', [$timeStamp, $formId, $fieldName]);
68 -
68 +
69 69 return apply_filters('fluentform/generated_protection_token', Protector::encrypt($data), $formId, $timeStamp);
70 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 + }
71 99
72 100 public function verify($insertData, $requestData, $formId)
73 101 {
74 - if (
75 - !$this->isEnabled($formId) ||
76 - (
77 - Helper::isConversionForm($formId) &&
78 - Arr::isTrue($requestData, 'isFFConversational')
79 - )
80 - ) {
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)) {
81 108 return;
82 109 }
83 -
110 +
84 111 $fieldName = $this->getFieldName($formId);
85 112 $token = sanitize_text_field(Arr::get($requestData, $fieldName));
86 113 if (!$token || !$this->validateToken($token, $formId)) {
87 114 $errorMessage = apply_filters(
@@ -120,8 +147,16 @@
120 147 return false;
121 148 }
122 149
123 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 +
124 159 return apply_filters('fluentform/token_based_validation_result',
125 160 $isValid,
126 161 $timestamp,
127 162 $tokenFormId,