PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / V3.3.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder vV3.3.2
V3.3.2 3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 All 139 releases
← All changes | includes/Frontend/Form/FrontendFormManager.php +426 -214 V-3.3.0 → V3.3.2 View file →
@@ -17,11 +17,14 @@
17 17 use BitCode\BitForm\Core\Form\Validator\FormFieldValidator;
18 18 use BitCode\BitForm\Core\Integration\IntegrationHandler;
19 19 use BitCode\BitForm\Core\Messages\SuccessMessageHandler;
20 20 use BitCode\BitForm\Core\Util\ApiResponse as UtilApiResponse;
21 +use BitCode\BitForm\Core\Util\EscapingHelper;
21 22 use BitCode\BitForm\Core\Util\FieldValueHandler;
23 +use BitCode\BitForm\Core\Util\FrontendHelpers;
22 24 use BitCode\BitForm\Core\Util\HttpHelper;
23 25 use BitCode\BitForm\Core\Util\IpTool;
26 +use BitCode\BitForm\Core\Util\Log;
24 27 use BitCode\BitForm\Core\Util\Utilities;
25 28 use BitCode\BitForm\Core\WorkFlow\WorkFlow;
26 29 use BitCode\BitForm\Frontend\Form\View\FormViewer;
27 30 use BitCode\BitForm\GlobalHelper;
@@ -29,8 +32,11 @@
29 32
30 33 final class FrontendFormManager extends FormManager
31 34 {
32 35 private $_form_identifier;
36 +
37 + /** @var array|null reCAPTCHA v3 verdict, reused for the log */
38 + private $recaptchaV3Verification;
33 39 private $_form_token;
34 40 private $_form_id;
35 41 private $_conf_messages;
36 42 private static $_instance = [];
@@ -187,13 +193,10 @@
187 193 {
188 194 $url = wp_parse_url(wp_get_referer());
189 195 $parameter = [];
190 196 if (isset($url['query'])) {
191 - $queries = explode('&', $url['query']);
192 - foreach ($queries as $query) {
193 - list($field, $value) = explode('=', $query);
194 - $parameter[$field] = $value;
195 - }
197 + // a bare key (?preview) made the old explode() split warn; this also URL-decodes values
198 + wp_parse_str($url['query'], $parameter);
196 199 }
197 200 return $parameter;
198 201 }
199 202
@@ -228,8 +231,119 @@
228 231
229 232 return $post;
230 233 }
231 234
235 + /**
236 + * WP auth errors carry markup and the confirmation box paints them with innerHTML,
237 + * so esc_html() would show the tags as text. kses keeps only the safe markup.
238 + *
239 + * @param mixed $message
240 + *
241 + * @return string
242 + */
243 + private static function authErrorMessage($message)
244 + {
245 + return wp_kses(is_string($message) ? $message : '', EscapingHelper::getAllowedHtmlTags());
246 + }
247 +
248 + /**
249 + * A confirm-enabled email/password field posts as one composite and the validator collapses it
250 + * to the primary value, so the confirm child's own field key never reaches $_POST. WP auth
251 + * integrations map fields by key, so fill those child keys on a copy for the auth filter.
252 + *
253 + * @param mixed $postData
254 + *
255 + * @return mixed
256 + */
257 + private function resolveConfirmChildValues($postData)
258 + {
259 + if (!is_array($postData)) {
260 + return $postData;
261 + }
262 + $fields = $this->getFields();
263 + $postData = $this->resolveCompositeChildValues($postData, $fields);
264 + foreach ($fields as $fieldKey => $fieldData) {
265 + if (
266 + empty($fieldData['childFields'])
267 + || !isset($fieldData['type'])
268 + || !in_array($fieldData['type'], ['email', 'password'], true)
269 + || !empty($fieldData['repeated'])
270 + || !isset($postData[$fieldKey])
271 + ) {
272 + continue;
273 + }
274 + $parentValue = $postData[$fieldKey];
275 + foreach ((array) $fieldData['childFields'] as $childFieldRef) {
276 + $childKey = is_object($childFieldRef) && isset($childFieldRef->fldKey) ? $childFieldRef->fldKey : '';
277 + if (
278 + empty($childKey)
279 + || !isset($fields[$childKey])
280 + || !empty($fields[$childKey]['isDeactive'])
281 + || isset($postData[$childKey])
282 + ) {
283 + continue;
284 + }
285 + if (is_array($parentValue)) {
286 + if (array_key_exists('confirm', $parentValue)) {
287 + $postData[$childKey] = $parentValue['confirm'];
288 + }
289 + continue;
290 + }
291 + // Validation matched primary against confirm before collapsing, so this is that value.
292 + $postData[$childKey] = $parentValue;
293 + }
294 + if (is_array($parentValue) && array_key_exists('primary', $parentValue)) {
295 + $postData[$fieldKey] = $parentValue['primary'];
296 + }
297 + }
298 +
299 + return $postData;
300 + }
301 +
302 + /**
303 + * Name/Address parts post as one array under the parent key ("parent[first_name]"), so a WP
304 + * auth mapping that points at a part's own key (First Name, Last Name) found nothing. Copy each
305 + * part's value to its key; the parent value is left as it was.
306 + *
307 + * @param array $postData
308 + * @param array $fields getFields() output
309 + *
310 + * @return array
311 + */
312 + private function resolveCompositeChildValues($postData, $fields)
313 + {
314 + foreach ($fields as $fieldKey => $fieldData) {
315 + if (
316 + empty($fieldData['childFields'])
317 + || !isset($fieldData['type'])
318 + || !in_array($fieldData['type'], ['name', 'address'], true)
319 + || !empty($fieldData['repeated'])
320 + || !isset($postData[$fieldKey])
321 + || !is_array($postData[$fieldKey])
322 + ) {
323 + continue;
324 + }
325 + foreach ((array) $fieldData['childFields'] as $childFieldRef) {
326 + $childKey = is_object($childFieldRef) && isset($childFieldRef->fldKey) ? $childFieldRef->fldKey : '';
327 + if (
328 + empty($childKey)
329 + || !isset($fields[$childKey])
330 + || !empty($fields[$childKey]['isDeactive'])
331 + || isset($postData[$childKey])
332 + || !preg_match('/\[([^\]]+)\]$/', (string) ($fields[$childKey]['name'] ?? ''), $matches)
333 + ) {
334 + continue;
335 + }
336 + $value = FieldValueHandler::extractChildValueFromParentValue($postData[$fieldKey], $matches[1], $childKey);
337 + if (null !== $value) {
338 + $postData[$childKey] = $value;
339 + }
340 + }
341 + }
342 +
343 + return $postData;
344 + }
345 +
232 346 public function handleSubmission()
233 347 {
234 348 // CSRF verified via verifySubmissionNonce() before this method is called. All $_POST reads below occur after that verification.
235 349 $this->fieldNameReplaceOfPost();
@@ -239,9 +353,9 @@
239 353 $validated = apply_filters('bitform_filter_form_validation', $validated, $this->_form_id);
240 354
241 355 if (true === $validated) {
242 356 do_action('bitform_validation_success', $this->_form_id);
243 - unset($_POST['hidden_fields']);
357 + $this->discardHiddenFieldValues();
244 358
245 359 $redirectPage = '';
246 360 $regSuccMsg = '';
247 361
@@ -251,17 +365,18 @@
251 365 $parameter = $this->getParams();
252 366 $existAuthFilter = has_filter('bitform_wp_user_auth');
253 367
254 368 if (true === $existAuthFilter) {
255 - $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $unslashed_post, $parameter);
369 + $authPostData = $this->resolveConfirmChildValues($unslashed_post);
370 + $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $authPostData, $parameter);
256 371
257 - $result = apply_filters('bitform_filter_wp_user_auth_response', $result, $this->_form_id, $unslashed_post, $parameter);
372 + $result = apply_filters('bitform_filter_wp_user_auth_response', $result, $this->_form_id, $authPostData, $parameter);
258 373
259 - do_action('bitform_wp_user_auth_response', $result, $this->_form_id, $unslashed_post, $parameter);
374 + do_action('bitform_wp_user_auth_response', $result, $this->_form_id, $authPostData, $parameter);
260 375
261 376 if (isset($result['auth_type']) && 'register' === $result['auth_type']) {
262 377 if (!$result['success']) {
263 - return new WP_Error('errors', esc_html($result['message']));
378 + return new WP_Error('errors', self::authErrorMessage($result['message']));
264 379 } elseif (isset($result['success'])) {
265 380 $redirectPage = $result['redirectPage'];
266 381 $regSuccMsg = $result['message'];
267 382 }
@@ -266,9 +381,9 @@
266 381 $regSuccMsg = $result['message'];
267 382 }
268 383 } else {
269 384 if (!$result['success']) {
270 - return new WP_Error('errors', esc_html($result['message']));
385 + return new WP_Error('errors', self::authErrorMessage($result['message']));
271 386 } else {
272 387 return $result;
273 388 }
274 389 }
@@ -286,38 +401,9 @@
286 401 $newPost = $this->transformDrpdwnValue($unslashed_post);
287 402 $filesData = GlobalHelper::sanitize_files_input($_FILES);
288 403 do_action('bitform_submit_success', $this->_form_id, $entryID, $newPost, $filesData);
289 404
290 - $captchaV3Settings = $this->getCaptchaV3Settings();
291 - if ($captchaV3Settings) {
292 - $token = isset($_POST['g-recaptcha-response']) ? sanitize_text_field(wp_unslash($_POST['g-recaptcha-response'])) : '';
293 - $integrationHandler = new IntegrationHandler(0);
294 - $allFormIntegrations = $integrationHandler->getAllIntegration('app', 'gReCaptchaV3');
295 - if (!is_wp_error($allFormIntegrations)) {
296 - foreach ($allFormIntegrations as $integration) {
297 - if (!is_null($integration->integration_type) && 'gReCaptchaV3' === $integration->integration_type) {
298 - $integrationDetails = Utilities::jsonObj($integration->integration_details);
299 - if ($integrationDetails) {
300 - $integrationDetails->id = $integration->id;
301 - $reCAPTCHA = $integrationDetails;
302 - }
303 - }
304 - }
305 - }
306 - if (!empty($reCAPTCHA->secretKey)) {
307 - $gRecaptchaResponse = HttpHelper::post(
308 - 'https://www.google.com/recaptcha/api/siteverify',
309 - ['secret' => $reCAPTCHA->secretKey, 'response' => $token]
310 - );
311 - if ($captchaV3Settings && !empty($saveResponse['triggerData'])) {
312 - $logID = $saveResponse['triggerData']['logID'];
313 - $integId = $reCAPTCHA->id;
314 - $saveApiResponse = new UtilApiResponse();
315 - $saveApiResponse->apiResponse($logID, $integId, ['type_name' => 'ReCaptcha', 'type' => 'v3'], 'success', $gRecaptchaResponse);
316 - }
317 - }
318 - unset($_POST['g-recaptcha-response']);
319 - }
405 + $this->logRecaptchaV3Verification($saveResponse);
320 406 if (!empty($redirectPage) && empty($saveResponse['redirectPage']) || null === $saveResponse['redirectPage']) {
321 407 $saveResponse['redirectPage'] = $redirectPage;
322 408 }
323 409 if (!empty($regSuccMsg) && isset($saveResponse['dflt_message'])) {
@@ -350,9 +436,10 @@
350 436 return new WP_Error('empty_form', __('Entries id is invalid', 'bit-form'));
351 437 }
352 438 if (true === $validated) {
353 439 do_action('bitform_validation_success', $this->_form_id);
354 - unset($_POST['hidden_fields'], $_POST['entryID']);
440 + $this->discardHiddenFieldValues();
441 + unset($_POST['entryID']);
355 442
356 443 $redirectPage = '';
357 444 $regSuccMsg = '';
358 445 $postData = wp_unslash($_POST);
@@ -362,13 +449,14 @@
362 449 $parameter = $this->getParams();
363 450 $existAuthFilter = has_filter('bitform_wp_user_auth');
364 451
365 452 if (true === $existAuthFilter) {
366 - $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $postData, $parameter);
453 + $authPostData = $this->resolveConfirmChildValues($postData);
454 + $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $authPostData, $parameter);
367 455
368 456 if (isset($result['auth_type']) && 'register' === $result['auth_type']) {
369 457 if (!$result['success']) {
370 - return new WP_Error('errors', esc_html($result['message']));
458 + return new WP_Error('errors', self::authErrorMessage($result['message']));
371 459 } elseif (isset($result['success'])) {
372 460 $redirectPage = $result['redirectPage'];
373 461 $regSuccMsg = $result['message'];
374 462 }
@@ -373,9 +461,9 @@
373 461 $regSuccMsg = $result['message'];
374 462 }
375 463 } else {
376 464 if (!$result['success']) {
377 - return new WP_Error('errors', esc_html($result['message']));
465 + return new WP_Error('errors', self::authErrorMessage($result['message']));
378 466 } else {
379 467 return $result;
380 468 }
381 469 }
@@ -394,38 +482,9 @@
394 482 //TO DO:: submit success action temporarily added for solution of a issue
395 483 do_action('bitform_submit_success', $this->_form_id, $entryID, $newPost, $filesData);
396 484 do_action('bitform_update_success', $this->_form_id, $entryID, $newPost, $filesData);
397 485
398 - $captchaV3Settings = $this->getCaptchaV3Settings();
399 - if ($captchaV3Settings) {
400 - $token = isset($_POST['g-recaptcha-response']) ? sanitize_text_field(wp_unslash($_POST['g-recaptcha-response'])) : '';
401 - $integrationHandler = new IntegrationHandler(0);
402 - $allFormIntegrations = $integrationHandler->getAllIntegration('app', 'gReCaptchaV3');
403 - if (!is_wp_error($allFormIntegrations)) {
404 - foreach ($allFormIntegrations as $integration) {
405 - if (!is_null($integration->integration_type) && 'gReCaptchaV3' === $integration->integration_type) {
406 - $integrationDetails = Utilities::jsonObj($integration->integration_details);
407 - if ($integrationDetails) {
408 - $integrationDetails->id = $integration->id;
409 - $reCAPTCHA = $integrationDetails;
410 - }
411 - }
412 - }
413 - }
414 - if (!empty($reCAPTCHA->secretKey)) {
415 - $gRecaptchaResponse = HttpHelper::post(
416 - 'https://www.google.com/recaptcha/api/siteverify',
417 - ['secret' => $reCAPTCHA->secretKey, 'response' => $token]
418 - );
419 - if ($captchaV3Settings && !empty($updateResponse['triggerData'])) {
420 - $logID = $updateResponse['triggerData']['logID'];
421 - $integId = $reCAPTCHA->id;
422 - $saveApiResponse = new UtilApiResponse();
423 - $saveApiResponse->apiResponse($logID, $integId, ['type_name' => 'ReCaptcha', 'type' => 'v3'], 'success', $gRecaptchaResponse);
424 - }
425 - }
426 - unset($_POST['g-recaptcha-response']);
427 - }
486 + $this->logRecaptchaV3Verification($updateResponse);
428 487 if (!empty($redirectPage) && empty($updateResponse['redirectPage']) || null === $updateResponse['redirectPage']) {
429 488 $updateResponse['redirectPage'] = $redirectPage;
430 489 }
431 490 if (!empty($regSuccMsg) && isset($updateResponse['dflt_message'])) {
@@ -444,11 +503,96 @@
444 503 do_action('bitform_validation_error', $this->_form_id, $validated);
445 504 return $validated;
446 505 }
447 506
507 + /**
508 + * Drop the posted `hidden_fields` transport key and, when the form opts in, the values of
509 + * the fields it names.
510 + *
511 + * A hidden field keeps its typed value in the DOM, so the browser still submits it. Runs
512 + * here because it is the last point before entry, notifications and integrations are built
513 + * from $_POST.
514 + *
515 + * @return void
516 + */
517 + private function discardHiddenFieldValues()
518 + {
519 + // CSRF verified upstream via verifySubmissionNonce(); $_POST is only being narrowed here.
520 + $rawHiddenFields = isset($_POST['hidden_fields']) ? wp_unslash($_POST['hidden_fields']) : '';
521 + unset($_POST['hidden_fields']);
522 +
523 + if (!$this->shouldDiscardHiddenFieldValues()) {
524 + return;
525 + }
526 + $hiddenFieldKeys = FrontendHelpers::parseHiddenFieldKeys($rawHiddenFields);
527 + if (empty($hiddenFieldKeys)) {
528 + return;
529 + }
530 +
531 + $formFields = $this->getFields();
532 + foreach ($hiddenFieldKeys as $fieldKey) {
533 + if (!isset($formFields[$fieldKey])) {
534 + continue;
535 + }
536 + $field = $formFields[$fieldKey];
537 + // The posted list also names builder-hidden and hidden-type fields, which carry a value
538 + // on purpose. Only what conditional logic hid is discarded.
539 + if ('hidden' === $field['type'] || !empty($field['valid']['hide'])) {
540 + continue;
541 + }
542 + // Hiding flags a repeater child once, not per row, so discarding would wipe the column
543 + // in every row.
544 + if (!empty($field['repeated'])) {
545 + continue;
546 + }
547 + // Calculation and tracking fields opt out.
548 + if (!empty($field['valid']['keepValueWhenHidden'])) {
549 + continue;
550 + }
551 + // A composite child (name/address/confirm) posts nested under its parent key.
552 + if (!empty($field['parentFieldKey'])) {
553 + $this->discardCompositeChildValue($formFields, $field, $fieldKey);
554 + continue;
555 + }
556 + unset($_POST[$fieldKey], $_FILES[$fieldKey]);
557 + }
558 + }
559 +
560 + /**
561 + * @param array $formFields
562 + * @param array $field the child field's config
563 + * @param string $fieldKey the child field's key
564 + *
565 + * @return void
566 + */
567 + private function discardCompositeChildValue($formFields, $field, $fieldKey)
568 + {
569 + $parentKey = $field['parentFieldKey'];
570 + if (!isset($_POST[$parentKey]) || !is_array($_POST[$parentKey])) {
571 + return;
572 + }
573 + $parentName = isset($formFields[$parentKey]['name']) ? $formFields[$parentKey]['name'] : '';
574 + $childName = FieldValueHandler::deriveChildName(isset($field['name']) ? $field['name'] : '', $parentName);
575 + unset($_POST[$parentKey][$childName], $_POST[$parentKey][$fieldKey]);
576 + }
577 +
578 + /**
579 + * @return bool
580 + */
581 + private function shouldDiscardHiddenFieldValues()
582 + {
583 + $formInfo = $this->getFormInfo();
584 + if (!is_object($formInfo) || !isset($formInfo->submissionSettings)) {
585 + return false;
586 + }
587 + $submissionSettings = (object) $formInfo->submissionSettings;
588 +
589 + return !empty($submissionSettings->discardHiddenFieldValues);
590 + }
591 +
448 592 public function validateFormSubmission($submitted_data)
449 593 {
450 - $hidden_fields = isset($submitted_data['hidden_fields']) ? $submitted_data['hidden_fields'] : '';
594 + $hidden_fields = FrontendHelpers::parseHiddenFieldKeys(isset($submitted_data['hidden_fields']) ? $submitted_data['hidden_fields'] : '');
451 595 $submitted_fields = $this->getSubmittedFields($submitted_data);
452 596 $form_fields = $this->getFields();
453 597 $form_fields_names = array_keys($form_fields);
454 598 if ($this->isGCLIDEnabled()) {
@@ -454,9 +598,9 @@
454 598 if ($this->isGCLIDEnabled()) {
455 599 array_push($form_fields_names, 'GCLID');
456 600 }
457 601 foreach ($submitted_fields as $field) {
458 - if ('hidden_fields' !== $field && !in_array($field, $form_fields_names) || false !== strpos($hidden_fields, $field)) {
602 + if ('hidden_fields' !== $field && !in_array($field, $form_fields_names) || FrontendHelpers::isFieldHidden($hidden_fields, $field)) {
459 603 unset($submitted_data[$field]);
460 604 }
461 605 }
462 606 return $submitted_data;
@@ -606,144 +750,236 @@
606 750 return new WP_Error('token_expired', __('Token expired', 'bit-form'));
607 751 }
608 752 }
609 753
754 + /**
755 + * Whether the IP already has a completed entry; drafts (status 9) don't count.
756 + *
757 + * @param string $ipAddress Visitor IP
758 + *
759 + * @return bool
760 + */
761 + private function hasCompletedEntryFromIp($ipAddress)
762 + {
763 + $storedIp = IpTool::storageValue($ipAddress);
764 + if ('' === $storedIp) {
765 + return false;
766 + }
767 +
768 + $entries = (new FormEntryModel())->get(
769 + ['status'],
770 + [
771 + 'form_id' => $this->form_id,
772 + 'user_ip' => $storedIp,
773 + ]
774 + );
775 +
776 + if (is_wp_error($entries) || !is_array($entries)) {
777 + return false;
778 + }
779 +
780 + foreach ($entries as $entry) {
781 + if (9 !== (int) $entry->status) {
782 + return true;
783 + }
784 + }
785 +
786 + return false;
787 + }
788 +
610 789 private function verifyGRecaptcha()
611 790 {
612 791 $captchaSettings = $this->getCaptchaSettings();
613 792 $captchaV3Settings = $this->getCaptchaV3Settings();
614 - if ($captchaSettings || $captchaV3Settings) {
615 - $token = isset($_POST['g-recaptcha-response']) ? sanitize_text_field(wp_unslash($_POST['g-recaptcha-response'])) : '';
616 - if (!isset($_POST['g-recaptcha-response'])) {
617 - return new WP_Error('spam_detection', __('Please recheck your reCaptcha Configuration', 'bit-form'));
793 + if (!$captchaSettings && !$captchaV3Settings) {
794 + return;
795 + }
796 +
797 + if (!isset($_POST['g-recaptcha-response'])) {
798 + return new WP_Error('spam_detection', __('Please recheck your reCaptcha Configuration', 'bit-form'));
799 + }
800 + $token = sanitize_text_field(wp_unslash($_POST['g-recaptcha-response']));
801 +
802 + $appType = $captchaSettings ? 'gReCaptcha' : 'gReCaptchaV3';
803 + $reCAPTCHA = $this->getCaptchaApp($appType);
804 +
805 + // Fail closed: no secret key must not skip verification.
806 + if (empty($reCAPTCHA->secretKey)) {
807 + Log::debug_log("reCAPTCHA ({$appType}) is enabled on form {$this->form_id}, but no secret key is set in App Settings.");
808 + return new WP_Error('spam_detection', __('reCAPTCHA is not configured. Please contact the site administrator.', 'bit-form'));
809 + }
810 +
811 + $gRecaptchaResponse = HttpHelper::post(
812 + 'https://www.google.com/recaptcha/api/siteverify',
813 + ['secret' => $reCAPTCHA->secretKey, 'response' => $token]
814 + );
815 + if (is_wp_error($gRecaptchaResponse)) {
816 + return new WP_Error('spam_detection', __('Please verify reCAPTCHA', 'bit-form'));
817 + }
818 +
819 + // Tokens are single-use; keep this verdict for the log.
820 + if ('gReCaptchaV3' === $appType) {
821 + $this->recaptchaV3Verification = ['integId' => $reCAPTCHA->id, 'response' => $gRecaptchaResponse];
822 + }
823 +
824 + if ($captchaV3Settings && self::isRecaptchaScoreTooLow($gRecaptchaResponse, $captchaV3Settings)) {
825 + wp_send_json_error(
826 + sanitize_text_field((string) $captchaV3Settings->message)
827 + );
828 + }
829 +
830 + if (!is_object($gRecaptchaResponse) || empty($gRecaptchaResponse->success)) {
831 + return new WP_Error('spam_detection', __('Please verify reCAPTCHA', 'bit-form'));
832 + }
833 + }
834 +
835 + /**
836 + * Connected captcha app from App Settings, with its integration id.
837 + *
838 + * @param string $appType gReCaptcha, gReCaptchaV3, hcaptcha or turnstileCaptcha
839 + *
840 + * @return object|null
841 + */
842 + private function getCaptchaApp($appType)
843 + {
844 + $apps = (new IntegrationHandler(0))->getAllIntegration('app', $appType);
845 + if (is_wp_error($apps) || !is_array($apps)) {
846 + return null;
847 + }
848 +
849 + $reCAPTCHA = null;
850 + foreach ($apps as $integration) {
851 + if ($appType !== $integration->integration_type) {
852 + continue;
618 853 }
619 - $integrationHandler = new IntegrationHandler(0);
620 - $allFormIntegrations = $integrationHandler->getAllIntegration('app', $captchaSettings ? 'gReCaptcha' : 'gReCaptchaV3');
621 - if (!is_wp_error($allFormIntegrations)) {
622 - foreach ($allFormIntegrations as $integration) {
623 - if (!is_null($integration->integration_type) && $integration->integration_type === ($captchaSettings ? 'gReCaptcha' : 'gReCaptchaV3')) {
624 - $integrationDetails = Utilities::jsonObj($integration->integration_details);
625 - if ($integrationDetails) {
626 - $integrationDetails->id = $integration->id;
627 - $reCAPTCHA = $integrationDetails;
628 - }
629 - }
630 - }
854 + $details = Utilities::jsonObj($integration->integration_details);
855 + if (is_object($details)) {
856 + $details->id = $integration->id;
857 + $reCAPTCHA = $details;
631 858 }
632 - if (!empty($reCAPTCHA->secretKey)) {
633 - $gRecaptchaResponse = HttpHelper::post(
634 - 'https://www.google.com/recaptcha/api/siteverify',
635 - ['secret' => $reCAPTCHA->secretKey, 'response' => $token]
636 - );
637 - $isgReCaptchaVerified = false;
638 - if (!is_wp_error($gRecaptchaResponse)) {
639 - if (
640 - $captchaV3Settings
641 - && !empty($gRecaptchaResponse->score)
642 - && ((float) $gRecaptchaResponse->score < (float) $captchaV3Settings->score)
643 - ) {
644 - wp_send_json_error(
645 - sanitize_text_field((string) $captchaV3Settings->message)
646 - );
647 - }
859 + }
648 860
649 - $isgReCaptchaVerified = $gRecaptchaResponse->success;
650 - }
651 - if (!$isgReCaptchaVerified) {
652 - return new WP_Error('spam_detection', __('Please verify reCAPTCHA', 'bit-form'));
653 - }
654 - }
861 + return $reCAPTCHA;
862 + }
863 +
864 + /**
865 + * Log the reCAPTCHA v3 verdict for this submission.
866 + *
867 + * @param array $saveResponse Entry save/update response
868 + *
869 + * @return void
870 + */
871 + private function logRecaptchaV3Verification($saveResponse)
872 + {
873 + if (!$this->getCaptchaV3Settings()) {
874 + return;
655 875 }
876 + unset($_POST['g-recaptcha-response']);
877 +
878 + if (empty($this->recaptchaV3Verification) || empty($saveResponse['triggerData']['logID'])) {
879 + return;
880 + }
881 +
882 + $response = $this->recaptchaV3Verification['response'];
883 + $status = is_object($response) && !empty($response->success) ? 'success' : 'error';
884 + (new UtilApiResponse())->apiResponse(
885 + $saveResponse['triggerData']['logID'],
886 + $this->recaptchaV3Verification['integId'],
887 + ['type_name' => 'ReCaptcha', 'type' => 'v3'],
888 + $status,
889 + $response
890 + );
656 891 }
657 892
658 - private function verifyHCaptcha()
893 + /**
894 + * Whether the v3 score is below tolerance; 0.0 counts, blank tolerance means 0.6.
895 + *
896 + * @param mixed $response Decoded siteverify response
897 + * @param mixed $settings Form's recaptchav3 settings
898 + *
899 + * @return bool
900 + */
901 + private static function isRecaptchaScoreTooLow($response, $settings)
659 902 {
660 - $hCaptchaExist = $this->isFieldTypeExist('hcaptcha'); // You can rename this to getHCaptchaSettings() if needed
661 - if ($hCaptchaExist) {
662 - if (!isset($_POST['h-captcha-response'])) {
663 - return new WP_Error('spam_detection', __('Please verify hCaptcha', 'bit-form'));
664 - }
903 + if (!is_object($response) || !isset($response->score) || !is_numeric($response->score)) {
904 + return false;
905 + }
665 906
666 - $token = sanitize_text_field(wp_unslash($_POST['h-captcha-response']));
907 + $threshold = is_object($settings) && isset($settings->score) && is_numeric($settings->score)
908 + ? (float) $settings->score
909 + : 0.6;
667 910
668 - $integrationHandler = new IntegrationHandler(0);
669 - $allFormIntegrations = $integrationHandler->getAllIntegration('app', 'hcaptcha');
911 + return (float) $response->score < $threshold;
912 + }
670 913
671 - if (!is_wp_error($allFormIntegrations)) {
672 - foreach ($allFormIntegrations as $integration) {
673 - if (!is_null($integration->integration_type) && 'hcaptcha' === $integration->integration_type) {
674 - $integrationDetails = Utilities::jsonObj($integration->integration_details);
675 - if ($integrationDetails) {
676 - $integrationDetails->id = $integration->id;
677 - $hCaptcha = $integrationDetails;
678 - }
679 - }
680 - }
681 - }
914 + private function verifyHCaptcha()
915 + {
916 + if (!$this->isFieldTypeExist('hcaptcha')) {
917 + return;
918 + }
682 919
683 - if (!empty($hCaptcha->secretKey)) {
684 - $hCaptchaResponse = HttpHelper::post(
685 - 'https://api.hcaptcha.com/siteverify',
686 - [
687 - 'secret' => $hCaptcha->secretKey,
688 - 'response' => $token,
689 - 'remoteip' => (isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '')
690 - ]
691 - );
920 + if (!isset($_POST['h-captcha-response'])) {
921 + return new WP_Error('spam_detection', __('Please verify hCaptcha', 'bit-form'));
922 + }
923 + $token = sanitize_text_field(wp_unslash($_POST['h-captcha-response']));
924 + $hCaptcha = $this->getCaptchaApp('hcaptcha');
692 925
693 - $isVerified = false;
694 - if (!is_wp_error($hCaptchaResponse)) {
695 - $isVerified = $hCaptchaResponse->success;
696 - }
926 + // Fail closed: no secret key must not skip verification.
927 + if (empty($hCaptcha->secretKey)) {
928 + Log::debug_log("hCaptcha is enabled on form {$this->form_id}, but no secret key is set in App Settings.");
929 + return new WP_Error('spam_detection', __('hCaptcha is not configured. Please contact the site administrator.', 'bit-form'));
930 + }
697 931
698 - if (!$isVerified) {
699 - return new WP_Error('spam_detection', __('hCaptcha verification failed', 'bit-form'));
700 - }
701 - }
932 + $hCaptchaResponse = HttpHelper::post(
933 + 'https://api.hcaptcha.com/siteverify',
934 + [
935 + 'secret' => $hCaptcha->secretKey,
936 + 'response' => $token,
937 + 'remoteip' => (isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '')
938 + ]
939 + );
940 +
941 + if (is_wp_error($hCaptchaResponse) || !is_object($hCaptchaResponse) || empty($hCaptchaResponse->success)) {
942 + return new WP_Error('spam_detection', __('hCaptcha verification failed', 'bit-form'));
702 943 }
703 944 }
704 945
705 946 private function verifyTurnstileCaptcha()
706 947 {
707 - $turnstileExist = $this->isFieldTypeExist('turnstile');
708 - if ($turnstileExist) {
709 - if (!isset($_POST['cf-turnstile-response'])) {
710 - return new WP_Error('spam_detection', __('Please verify Cloudflare Turnstile Captcha', 'bit-form'));
711 - }
712 - $token = sanitize_text_field(wp_unslash($_POST['cf-turnstile-response']));
713 - $turnstileCaptcha = null;
714 - $integrationHandler = new IntegrationHandler(0);
715 - $turnstileIntegration = $integrationHandler->getAllIntegration('app', 'turnstileCaptcha')[0];
716 - if (!is_wp_error($turnstileIntegration && !is_null($turnstileIntegration->integration_type))) {
717 - $turnstileCaptcha = json_decode($turnstileIntegration->integration_details);
718 - // $integrationDetails->id = $turnstileIntegration->id;
719 - // $turnstileCaptcha = $integrationDetails;
720 - }
721 - if (!is_null($turnstileCaptcha)) {
722 - $isTurnstileCaptchaVerified = false;
723 - $turnstileRecaptchaResponse = HttpHelper::post(
724 - 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
725 - ['secret' => $turnstileCaptcha->secretKey, 'response' => $token]
726 - );
727 - if (!is_wp_error($turnstileRecaptchaResponse)) {
728 - if (!$turnstileRecaptchaResponse->success) {
729 - $errorCodes = implode(', ', (array) ($turnstileRecaptchaResponse->{'error-codes'} ?? []));
730 - wp_send_json_error(
731 - sprintf(
732 - /* translators: %s: dynamic value. */
733 - __('Cloudflare Turnstile Validation Error: %s', 'bit-form'),
734 - $errorCodes
735 - )
736 - );
737 - }
948 + if (!$this->isFieldTypeExist('turnstile')) {
949 + return;
950 + }
738 951
739 - $isTurnstileCaptchaVerified = $turnstileRecaptchaResponse->success;
740 - }
741 - if (!$isTurnstileCaptchaVerified) {
742 - return new WP_Error('spam_detection', __('Please verify Cloudflare Turnstile Captcha', 'bit-form'));
743 - }
744 - }
952 + if (!isset($_POST['cf-turnstile-response'])) {
953 + return new WP_Error('spam_detection', __('Please verify Cloudflare Turnstile Captcha', 'bit-form'));
745 954 }
955 + $token = sanitize_text_field(wp_unslash($_POST['cf-turnstile-response']));
956 + $turnstileCaptcha = $this->getCaptchaApp('turnstileCaptcha');
957 +
958 + // Fail closed: no secret key must not skip verification.
959 + if (empty($turnstileCaptcha->secretKey)) {
960 + Log::debug_log("Turnstile is enabled on form {$this->form_id}, but no secret key is set in App Settings.");
961 + return new WP_Error('spam_detection', __('Cloudflare Turnstile is not configured. Please contact the site administrator.', 'bit-form'));
962 + }
963 +
964 + $turnstileResponse = HttpHelper::post(
965 + 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
966 + ['secret' => $turnstileCaptcha->secretKey, 'response' => $token]
967 + );
968 + if (is_wp_error($turnstileResponse) || !is_object($turnstileResponse)) {
969 + return new WP_Error('spam_detection', __('Please verify Cloudflare Turnstile Captcha', 'bit-form'));
970 + }
971 +
972 + if (empty($turnstileResponse->success)) {
973 + $errorCodes = implode(', ', (array) ($turnstileResponse->{'error-codes'} ?? []));
974 + wp_send_json_error(
975 + sprintf(
976 + /* translators: %s: dynamic value. */
977 + __('Cloudflare Turnstile Validation Error: %s', 'bit-form'),
978 + $errorCodes
979 + )
980 + );
981 + }
746 982 }
747 983
748 984 public function verifySubmissionNonce()
749 985 {
@@ -827,33 +1063,9 @@
827 1063 continue;
828 1064 }
829 1065
830 1066 if ('onePerIp' === $restrictionKey) {
831 - $formEntry = new FormEntryModel();
832 -
833 - $getResult = $formEntry->get(
834 - ['user_ip', 'status'],
835 - [
836 - 'form_id' => $this->form_id,
837 - 'user_ip' => (int) ip2long((string) $ipAddress)
838 - ],
839 - );
840 -
841 - $count = 0;
842 - $status = 0;
843 -
844 - if (!is_wp_error($getResult) && count($getResult) > 0) {
845 - $count = count($getResult);
846 -
847 - foreach ($getResult as $row) {
848 - if (9 === (int) $row->status) {
849 - $status = 9;
850 - break;
851 - }
852 - }
853 - }
854 -
855 - if ($count > 0 && 9 !== (int) $status) {
1067 + if ($this->hasCompletedEntryFromIp($ipAddress)) {
856 1068 $onePerIp = __('Sorry!! You have already submitted from this IP address', 'bit-form');
857 1069
858 1070 $onePerIp = apply_filters(
859 1071 'bitform_filter_restriction_one_per_ip_message',