PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.15
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.15
6.2.15 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 All 197 releases
fluentform / app / Services / Form / FormValidationService.php

FormValidationService.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.15, at app/Services/Form/FormValidationService.php

1,303 lines 47.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Services\Form;
4
5 use FluentForm\App\Helpers\Helper;
6 use FluentForm\App\Models\FormMeta;
7 use FluentForm\App\Modules\Form\AkismetHandler;
8 use FluentForm\App\Modules\Form\CleanTalkHandler;
9 use FluentForm\App\Modules\Form\FormDataParser;
10 use FluentForm\App\Modules\Form\FormFieldsParser;
11 use FluentForm\App\Modules\HCaptcha\HCaptcha;
12 use FluentForm\App\Modules\ReCaptcha\ReCaptcha;
13 use FluentForm\App\Modules\Turnstile\Turnstile;
14 use FluentForm\App\Services\FormBuilder\Components\SelectCountry;
15 use FluentForm\Framework\Foundation\App;
16 use FluentForm\Framework\Helpers\ArrayHelper as Arr;
17 use FluentForm\Framework\Validator\ValidationException;
18
19 class FormValidationService
20 {
21 /** Skip a provider that just failed rather than re-timing-out per submission. */
22 const GEO_BACKOFF_MINUTES = 15;
23
24 const GEO_TIMEOUT = 3;
25
26 /** Resolved countries are reused so a flood cannot burn provider quota. */
27 const GEO_CACHE_MINUTES = 10;
28
29 /** Entries per cache shard; 256 shards keeps rows small and uncontended. */
30 const GEO_CACHE_SHARD_MAX = 25;
31
32 /** Consecutive inconclusive answers before a provider is treated as down. */
33 const GEO_PROVIDER_STRIKES = 3;
34
35 protected $app;
36 protected $form;
37 protected $formData;
38
39 public function __construct()
40 {
41 $this->app = App::getInstance();
42 }
43
44 public function setForm($form)
45 {
46 $this->form = $form;
47 }
48
49 public function setFormData($formData)
50 {
51 $this->formData = $formData;
52 }
53
54 /**
55 * @param $fields
56 * @param $formData
57 * @return bool
58 * @throws ValidationException
59 */
60 public function validateSubmission(&$fields, &$formData)
61 {
62 do_action('fluentform/before_form_validation', $fields, $formData);
63
64 $this->preventMaliciousAttacks();
65
66 $this->validateRestrictions($fields);
67
68 $this->validateNonce();
69
70 $this->validateReCaptcha();
71 $this->validateHCaptcha();
72 $this->validateTurnstile();
73
74 foreach ($fields as $fieldName => $field) {
75 if (isset($formData[$fieldName])) {
76 $element = $field['element'];
77
78 $formData[$fieldName] = apply_filters_deprecated('fluentform_input_data_' . $element, [
79 $formData[$fieldName],
80 $field,
81 $formData,
82 $this->form
83 ],
84 FLUENTFORM_FRAMEWORK_UPGRADE,
85 'fluentform/input_data_' . $element,
86 'Use fluentform/input_data_' . $element . ' instead of fluentform_input_data_' . $element
87 );
88 $formData[$fieldName] = apply_filters('fluentform/input_data_' . $element, $formData[$fieldName], $field, $formData, $this->form);
89 }
90 }
91
92 $originalValidations = FormFieldsParser::getValidations($this->form, $formData, $fields);
93
94 // Fire an event so that one can hook into it to work with the rules & messages.
95 $originalValidations = apply_filters_deprecated('fluentform_validations', [
96 $originalValidations,
97 $this->form,
98 $formData
99 ],
100 FLUENTFORM_FRAMEWORK_UPGRADE,
101 'fluentform/validations',
102 'Use fluentform/validations instead of fluentform_validations.'
103 );
104 $validations = apply_filters('fluentform/validations', $originalValidations, $this->form, $formData);
105
106 /*
107 * Clean talk fix for now
108 * They should not hook fluentform_validations and return nothing!
109 * We will remove this extra check once it's done
110 */
111 if ($originalValidations && (!$validations || !array_filter($validations))) {
112 $validations = $originalValidations;
113 }
114
115 $validator = wpFluentForm('validator')->make($formData, $validations[0], $validations[1]);
116
117 $errors = [];
118 if ($validator->validate()->fails()) {
119 foreach ($validator->errors() as $attribute => $rules) {
120 $position = strpos($attribute, ']');
121
122 if ($position) {
123 $attribute = substr($attribute, 0, strpos($attribute, ']') + 1);
124 }
125
126 $errors[$attribute] = $rules;
127 }
128 // Fire an event so that one can hook into it to work with the errors.
129 $errors = apply_filters_deprecated('fluentform_validation_error', [
130 $errors,
131 $this->form,
132 $fields,
133 $formData
134 ],
135 FLUENTFORM_FRAMEWORK_UPGRADE,
136 'fluentform/validation_error',
137 'Use fluentform/validation_error instead of fluentform_validation_error.'
138 );
139
140 $errors = $this->app->applyFilters('fluentform/validation_error', $errors, $this->form, $fields, $formData);
141 }
142
143 foreach ($fields as $fieldKey => $field) {
144 $field['data_key'] = $fieldKey;
145 $inputName = Arr::get($field, 'raw.attributes.name');
146 $field['name'] = $inputName;
147 $error = $this->validateInput($field, $formData, $this->form);
148
149 // Deliberately here and not inside Helper::validateInput(): that
150 // answers "is this value legal for this field" and is reused by entry
151 // import, which would silently drop a historical row that breaches a
152 // limit added later. How many options may be picked is a rule about
153 // this submission, so it is enforced on this path only.
154 if (!$error) {
155 $error = Helper::validateSelectionLimits(
156 Arr::get($field, 'raw', $field),
157 Arr::get($formData, $inputName)
158 );
159 }
160 $error = apply_filters_deprecated('fluentform_validate_input_item_' . $field['element'], [
161 $error,
162 $field,
163 $formData,
164 $fields,
165 $this->form,
166 $errors
167 ],
168 FLUENTFORM_FRAMEWORK_UPGRADE,
169 'fluentform/validate_input_item_' . $field['element'],
170 'Use fluentform/validate_input_item_' . $field['element'] . ' instead of fluentform_validate_input_item_' . $field['element']
171 );
172
173 $error = apply_filters('fluentform/validate_input_item_' . $field['element'], $error, $field, $formData, $fields, $this->form, $errors);
174 if ($error) {
175 if (empty($errors[$inputName])) {
176 $errors[$inputName] = [];
177 }
178 if (is_string($error)) {
179 $error = [fluentform_sanitize_html($error)];
180 } else {
181 if (is_array($error)) {
182 foreach ($error as $rule => $message) {
183 $error[$rule] = fluentform_sanitize_html($message);
184 }
185 }
186 }
187 $errors[$inputName] = array_merge($error, $errors[$inputName]);
188 }
189 }
190
191 $errors = apply_filters_deprecated('fluentform_validation_errors', [
192 $errors,
193 $formData,
194 $this->form,
195 $fields
196 ],
197 FLUENTFORM_FRAMEWORK_UPGRADE,
198 'fluentform/validation_errors',
199 'Use fluentform/validation_errors instead of fluentform_validation_errors.'
200 );
201
202 $errors = apply_filters('fluentform/validation_errors', $errors, $formData, $this->form, $fields);
203
204 if ('yes' == Helper::getFormMeta($this->form->id, '_has_user_registration') && !get_current_user_id()) {
205 $errors = apply_filters_deprecated('fluentform_validation_user_registration_errors', [
206 $errors,
207 $formData,
208 $this->form,
209 $fields
210 ],
211 FLUENTFORM_FRAMEWORK_UPGRADE,
212 'fluentform/validation_user_registration_errors',
213 'Use fluentform/validation_user_registration_errors instead of fluentform_validation_user_registration_errors.'
214 );
215
216 $errors = apply_filters('fluentform/validation_user_registration_errors', $errors, $formData, $this->form, $fields);
217 }
218
219 if ('yes' == Helper::getFormMeta($this->form->id, '_has_user_update') && get_current_user_id()) {
220 $errors = apply_filters_deprecated('fluentform_validation_user_update_errors', [
221 $errors,
222 $formData,
223 $this->form,
224 $fields
225 ],
226 FLUENTFORM_FRAMEWORK_UPGRADE,
227 'fluentform/validation_user_update_errors',
228 'Use fluentform/validation_user_update_errors instead of fluentform_validation_user_update_errors.'
229 );
230 $errors = apply_filters('fluentform/validation_user_update_errors', $errors, $formData, $this->form, $fields);
231 }
232
233 if ('update' == Arr::get(Helper::getFormMeta($this->form->id, 'postFeeds'), 'post_form_type')) {
234 $errors = apply_filters('fluentform/validation_post_update_errors', $errors, $formData, $this->form, $fields);
235 }
236
237 if ($errors) {
238 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
239 throw new ValidationException('', 423, null, ['errors' => $errors]);
240 }
241
242 return true;
243 }
244
245 protected function validateInput($field, $formData, $form, $fieldName = '', $inputValue = [])
246 {
247 return Helper::validateInput($field, $formData, $form, $fieldName, $inputValue);
248 }
249
250 /**
251 * Prevents malicious attacks when the submission
252 * count exceeds in an allowed interval.
253 * @throws ValidationException
254 */
255 public function preventMaliciousAttacks()
256 {
257 $prevent = apply_filters('fluentform/prevent_malicious_attacks', true, $this->form->id);
258
259 if ($prevent) {
260 $maxSubmissionCount = apply_filters('fluentform/max_submission_count', 5, $this->form->id);
261 $minSubmissionInterval = apply_filters('fluentform/min_submission_interval', 30, $this->form->id);
262
263 $interval = date('Y-m-d H:i:s', strtotime(current_time('mysql')) - $minSubmissionInterval);
264
265 $clientIp = sanitize_text_field($this->app->request->getIp());
266 $submissionCount = wpFluent()->table('fluentform_submissions')
267 ->where('status', '!=', 'trashed')
268 ->where('ip', $clientIp ?: '0.0.0.0')
269 ->where('created_at', '>=', $interval)
270 ->count();
271
272 if ($submissionCount >= $maxSubmissionCount) {
273 throw new ValidationException('', 429, null, [
274 'errors' => [
275 'restricted' => [
276 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Sanitized by fluentform_sanitize_html
277 fluentform_sanitize_html(apply_filters(
278 'fluentform/too_many_requests',
279 __('Too Many Requests.', 'fluentform'),
280 $this->form->id
281 )),
282 ],
283 ]
284 ]);
285 }
286 }
287 }
288
289 /**
290 * Validate form data based on the form restrictions settings.
291 *
292 * @param $fields
293 * @throws ValidationException
294 */
295 private function validateRestrictions(&$fields)
296 {
297 $formSettings = FormMeta::retrieve('formSettings', $this->form->id);
298
299 $this->form->settings = is_array($formSettings) ? $formSettings : [];
300
301 $isAllowed = [
302 'status' => true,
303 'message' => '',
304 ];
305
306 // This will check the following restriction settings.
307 // 1. limitNumberOfEntries
308 // 2. scheduleForm
309 // 3. requireLogin
310 // 4. restricted submission based on ip, country and keywords
311
312 /* This filter is deprecated and will be removed soon */
313 $isAllowed = apply_filters('fluentform_is_form_renderable', $isAllowed, $this->form);
314
315 $isAllowed = apply_filters('fluentform/is_form_renderable', $isAllowed, $this->form);
316
317 if (!$isAllowed['status']) {
318 throw new ValidationException('', 422, null, [
319 'errors' => [
320 'restricted' => [
321 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Sanitized by fluentform_sanitize_html
322 fluentform_sanitize_html($isAllowed['message']),
323 ],
324 ],
325 ]);
326 }
327
328 // Since we are here, we should now handle if the form should be allowed to submit empty.
329 $restrictions = Arr::get($this->form->settings, 'restrictions.denyEmptySubmission', []);
330
331 $this->handleDenyEmptySubmission($restrictions, $fields);
332
333 $formRestrictions = Arr::get($this->form->settings, 'restrictions.restrictForm', []);
334
335 $this->handleRestrictedSubmission($formRestrictions, $fields);
336 }
337
338 /**
339 * Handle response when empty form submission is not allowed.
340 *
341 * @param array $settings
342 * @param $fields
343 * @throws ValidationException
344 */
345 private function handleDenyEmptySubmission($settings, &$fields)
346 {
347 // Determine whether empty form submission is allowed or not.
348 if (Arr::isTrue($settings, 'enabled')) {
349 // confirm this form has no required fields.
350 if (!FormFieldsParser::hasRequiredFields($this->form, $fields)) {
351 // Filter out the form data which doesn't have values.
352 $filteredFormData = array_filter(
353 // Filter out the other meta fields that aren't actual inputs.
354 array_intersect_key($this->formData, $fields)
355 );
356 if (!count(Helper::arrayFilterRecursive($filteredFormData))) {
357 $defaultMessage = esc_html(__('Sorry! You can\'t submit an empty form.','fluentform'));
358 $customMessage = Arr::get($settings, 'message');
359 $customMessage = fluentform_sanitize_html(apply_filters('fluentform/deny_empty_submission_message', $customMessage, $this->form));
360
361 throw new ValidationException('', 422, null, [
362 'errors' => [
363 'restricted' => [
364 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Sanitized by fluentform_sanitize_html
365 !empty($customMessage) ? fluentform_sanitize_html($customMessage) : fluentform_sanitize_html($defaultMessage),
366 ],
367 ],
368 ]);
369 }
370 }
371 }
372 }
373
374 /**
375 * Handle response when form submission is restricted based on ip, country or keywords.
376 *
377 * @param array $settings
378 * @param $fields
379 * @throws ValidationException
380 */
381 protected function handleRestrictedSubmission($settings, &$fields)
382 {
383 // Determine this restriction is enabled ot not
384 if (!Arr::isTrue($settings, 'enabled')) {
385 return;
386 }
387
388 $rawIp = $this->app->request->getIp();
389 if (is_array($rawIp)) {
390 $rawIp = Arr::get($rawIp, '0');
391 }
392 $ip = sanitize_text_field($rawIp);
393 if ($ip) {
394 $this->checkIpRestriction($settings, $ip);
395 }
396
397 $isCountryRestrictionEnabled = Arr::isTrue($settings, 'fields.country.status');
398 if ($isCountryRestrictionEnabled) {
399 $country = $this->resolveCountryFromIp($ip);
400
401 if (!$country) {
402 $this->handleUnresolvedCountry($settings);
403 }
404
405 $this->checkCountryRestriction($settings, $country);
406 }
407
408 $this->checkKeyWordRestriction($settings);
409 }
410
411
412 /**
413 * Validate nonce.
414 * @throws ValidationException
415 */
416 protected function validateNonce()
417 {
418 $formId = $this->form->id;
419 $shouldVerifyNonce = false;
420 /* This filter is deprecated and will be removed soon. */
421 $shouldVerifyNonce = $this->app->applyFilters('fluentform_nonce_verify', $shouldVerifyNonce, $formId);
422
423 $shouldVerifyNonce = $this->app->applyFilters('fluentform/nonce_verify', $shouldVerifyNonce, $formId);
424
425 if ($shouldVerifyNonce) {
426 $nonce = Arr::get($this->formData, '_fluentform_' . $formId . '_fluentformnonce');
427 if (!wp_verify_nonce($nonce, 'fluentform-submit-form')) {
428 $errors = apply_filters_deprecated(
429 'fluentForm_nonce_error',
430 [
431 [
432 '_fluentformnonce' => [
433 __('Nonce verification failed, please try again.', 'fluentform'),
434 ],
435 ],
436 ],
437 FLUENTFORM_FRAMEWORK_UPGRADE,
438 'fluentForm/nonce_error',
439 'Use fluentForm/nonce_error instead of fluentForm_nonce_error.'
440 );
441
442 $errors = $this->app->applyFilters('fluentform/nonce_error', $errors);
443 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
444 throw new ValidationException('', 422, null, ['errors' => $errors]);
445 }
446 }
447 }
448
449 /** Validate Akismet Spam
450 * @throws ValidationException
451 */
452 public function handleAkismetSpamError()
453 {
454 $settings = get_option('_fluentform_global_form_settings');
455 if (!$settings || 'validation_failed' != Arr::get($settings, 'misc.akismet_validation')) {
456 return;
457 }
458
459 $errors = [
460 '_fluentformakismet' => __('Submission marked as spammed. Please try again', 'fluentform'),
461 ];
462 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
463 throw new ValidationException('', 422, null, ['errors' => $errors]);
464 }
465
466 /** Validate CleanTalk Spam
467 * @throws ValidationException
468 */
469 public function handleCleanTalkSpamError()
470 {
471 $settings = get_option('_fluentform_global_form_settings');
472 if (!$settings || 'validation_failed' != Arr::get($settings, 'misc.cleantalk_validation')) {
473 return;
474 }
475
476 $errors = [
477 '_fluentformcleantalk' => __('Submission marked as spammed. Please try again', 'fluentform'),
478 ];
479 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
480 throw new ValidationException('', 422, null, ['errors' => $errors]);
481 }
482
483 /** Validate CleanTalk Spam While Using API
484 * @throws ValidationException
485 */
486 public function handleCleanTalkSpamErrorUsingAPi()
487 {
488 $cleantalkSettings = get_option('_fluentform_cleantalk_details');
489
490 if (
491 !$cleantalkSettings ||
492 'validation_failed' != Arr::get($cleantalkSettings, 'validation')
493 ) {
494 return;
495 }
496
497 $errors = [
498 '_fluentformcleantalk' => __('Submission marked as spammed. Please try again', 'fluentform'),
499 ];
500 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
501 throw new ValidationException('', 422, null, ['errors' => $errors]);
502 }
503
504 public function isAkismetSpam($formData, $form)
505 {
506 if (!AkismetHandler::isEnabled()) {
507 return false;
508 }
509 $isSpamCheck = apply_filters_deprecated(
510 'fluentform_akismet_check_spam',
511 [
512 true,
513 $form->id,
514 $formData
515 ],
516 FLUENTFORM_FRAMEWORK_UPGRADE,
517 'fluentform/akismet_check_spam',
518 'Use fluentform/akismet_check_spam instead of fluentform_akismet_check_spam.'
519 );
520
521 $isSpamCheck = apply_filters('fluentform/akismet_check_spam', $isSpamCheck, $form->id, $formData);
522
523 if (!$isSpamCheck) {
524 return false;
525 }
526 // Let's validate now
527 $isSpam = AkismetHandler::isSpamSubmission($formData, $form);
528
529 $isSpam = apply_filters_deprecated(
530 'fluentform_akismet_spam_result',
531 [
532 $isSpam,
533 $form->id,
534 $formData
535 ],
536 FLUENTFORM_FRAMEWORK_UPGRADE,
537 'fluentform/akismet_spam_result',
538 'Use fluentform/akismet_spam_result instead of fluentform_akismet_spam_result.'
539 );
540 return apply_filters('fluentform/akismet_spam_result', $isSpam, $form->id, $formData);
541 }
542
543 public function isCleanTalkSpam($formData, $form)
544 {
545 if (CleanTalkHandler::isCleantalkActivated() || CleanTalkHandler::hasExecuted()) {
546 return false;
547 }
548
549 if (!CleanTalkHandler::isEnabled()) {
550 return false;
551 }
552 $isSpamCheck = apply_filters('fluentform/cleantalk_check_spam', true, $form->id, $formData);
553
554 if (!$isSpamCheck) {
555 return false;
556 }
557 $isSpam = CleanTalkHandler::isSpamSubmission($formData, $form);
558
559 return apply_filters('fluentform/cleantalk_spam_result', $isSpam, $form->id, $formData);
560 }
561
562 public function isCleanTalkSpamUsingApi($formData, $form)
563 {
564 if (!CleanTalkHandler::isCleantalkActivated()) {
565 return false;
566 }
567
568 $isSpamCheck = apply_filters('fluentform/cleantalk_check_spam', true, $form->id, $formData);
569
570 if (!$isSpamCheck) {
571 return false;
572 }
573
574 $isSpam = CleanTalkHandler::spamSubmissionCheckWithApi($formData, $form);
575
576 return apply_filters('fluentform/cleantalk_spam_result', $isSpam, $form->id, $formData);
577 }
578
579 /**
580 * Validate reCaptcha.
581 * Uses 'fluentform/disable_captcha' filter with 'recaptcha' as the captcha type since 6.0.3
582 * @throws ValidationException
583 */
584 private function validateReCaptcha()
585 {
586 // Check if autoload_captcha is enabled and if it's not recaptcha, skip validation
587 if ($this->shouldSkipCaptchaValidation('recaptcha')) {
588 return;
589 }
590
591 $hasAutoRecap = apply_filters_deprecated(
592 'ff_has_auto_recaptcha',
593 [
594 false
595 ],
596 FLUENTFORM_FRAMEWORK_UPGRADE,
597 'fluentform/has_recaptcha',
598 'Use fluentform/has_recaptcha instead of ff_has_auto_recaptcha.'
599 );
600 $autoInclude = apply_filters('fluentform/has_recaptcha', $hasAutoRecap);
601 $disableReCaptcha = apply_filters('fluentform/disable_captcha', false, $this->form, 'recaptcha');
602
603 if (!$disableReCaptcha && (FormFieldsParser::hasElement($this->form, 'recaptcha') || $autoInclude)) {
604 $keys = get_option('_fluentform_reCaptcha_details');
605 $token = Arr::get($this->formData, 'g-recaptcha-response');
606 $version = 'v2_visible';
607 if (!empty($keys['api_version'])) {
608 $version = $keys['api_version'];
609 }
610 $isValid = ReCaptcha::validate($token, $keys['secretKey'], $version);
611
612 if (!$isValid) {
613 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
614 throw new ValidationException('', 422, null, [
615 'errors' => [
616 'g-recaptcha-response' => [
617 esc_html(__('reCaptcha verification failed, please try again.', 'fluentform')),
618 ],
619 ],
620 ]);
621 }
622 }
623 }
624
625 /**
626 * Validate hCaptcha.
627 *
628 * @throws ValidationException
629 */
630 private function validateHCaptcha()
631 {
632 // Check if autoload_captcha is enabled and if it's not hcaptcha, skip validation
633 if ($this->shouldSkipCaptchaValidation('hcaptcha')) {
634 return;
635 }
636 $hasAutoHcap = apply_filters_deprecated(
637 'ff_has_auto_hcaptcha',
638 [
639 false
640 ],
641 FLUENTFORM_FRAMEWORK_UPGRADE,
642 'fluentform/has_hcaptcha',
643 'Use fluentform/has_hcaptcha instead of ff_has_auto_hcaptcha.'
644 );
645 $autoInclude = apply_filters('fluentform/has_hcaptcha', $hasAutoHcap);
646 $disableHCaptcha = apply_filters('fluentform/disable_captcha', false, $this->form, 'hcaptcha');
647
648 FormFieldsParser::resetData();
649 if (!$disableHCaptcha && (FormFieldsParser::hasElement($this->form, 'hcaptcha') || $autoInclude)) {
650 $keys = get_option('_fluentform_hCaptcha_details');
651 $token = Arr::get($this->formData, 'h-captcha-response');
652 $isValid = HCaptcha::validate($token, $keys['secretKey']);
653
654 if (!$isValid) {
655 throw new ValidationException('', 422, null, [
656 'errors' => [
657 'h-captcha-response' => [
658 esc_html(__('hCaptcha verification failed, please try again.', 'fluentform')),
659 ],
660 ],
661 ]);
662 }
663 }
664 }
665
666 /**
667 * Validate turnstile.
668 *
669 * @throws ValidationException
670 */
671 private function validateTurnstile()
672 {
673 // Check if autoload_captcha is enabled and if it's not turnstile, skip validation
674 if ($this->shouldSkipCaptchaValidation('turnstile')) {
675 return;
676 }
677
678 $hasAutoTurnsTile = apply_filters_deprecated(
679 'ff_has_auto_turnstile',
680 [
681 false
682 ],
683 FLUENTFORM_FRAMEWORK_UPGRADE,
684 'fluentform/has_turnstile',
685 'Use fluentform/has_turnstile instead of ff_has_auto_turnstile.'
686 );
687 $autoInclude = apply_filters('fluentform/has_turnstile', $hasAutoTurnsTile);
688 $disableTurnsTile = apply_filters('fluentform/disable_captcha', false, $this->form, 'turnstile');
689
690 if (!$disableTurnsTile && (FormFieldsParser::hasElement($this->form, 'turnstile') || $autoInclude)) {
691 $keys = get_option('_fluentform_turnstile_details');
692 $token = Arr::get($this->formData, 'cf-turnstile-response');
693
694 $isValid = Turnstile::validate($token, $keys['secretKey']);
695
696 if (!$isValid) {
697 throw new ValidationException('', 422, null, [
698 'errors' => [
699 'cf-turnstile-response' => [
700 esc_html(__('Turnstile verification failed, please try again.', 'fluentform')),
701 ],
702 ],
703 ]);
704 }
705 }
706 }
707
708
709 /**
710 * Delegate the validation rules & messages to the
711 * ones that the validation library recognizes.
712 *
713 * @param $rules
714 * @param $messages
715 * @param array $search
716 * @param array $replace
717 * @return array
718 */
719 protected function delegateValidations($rules, $messages, $search = [], $replace = [])
720 {
721 $search = $search ?: ['max_file_size', 'allowed_file_types'];
722 $replace = $replace ?: ['max', 'mimes'];
723
724 foreach ($rules as &$rule) {
725 $rule = str_replace($search, $replace, $rule);
726 }
727
728 foreach ($messages as $key => $message) {
729 $newKey = str_replace($search, $replace, $key);
730 $messages[$newKey] = $message;
731 unset($messages[$key]);
732 }
733
734 return [$rules, $messages];
735 }
736
737 /**
738 * Decide what an unresolved country means for this rule.
739 *
740 * A block list stays permissive: the providers are third party, and their
741 * outage must not stop a site taking submissions. An allow list cannot be
742 * honoured at all without a country - letting it through would turn "only
743 * these countries" into "anyone" - so it fails closed. Either case can be
744 * inverted with the filter.
745 *
746 * @throws ValidationException
747 */
748 private function handleUnresolvedCountry($settings)
749 {
750 // A rule with no countries chosen cannot express an intent, so it must
751 // not acquire a brand new way to reject people.
752 if (!array_filter((array) Arr::get($settings, 'fields.country.values', []))) {
753 return;
754 }
755
756 // Derived negatively on purpose: checkCountryRestriction() treats
757 // anything that is not fail_on_condition_met as an allow list, and a
758 // form saved before validation_type existed has the key absent. Testing
759 // for the allow-list string instead would leave those forms enforced as
760 // an allow list while being failed open as a block list.
761 $isAllowList = 'fail_on_condition_met' !== Arr::get($settings, 'fields.country.validation_type');
762
763 $failClosed = apply_filters(
764 'fluentform/country_restriction_fail_closed',
765 $isAllowList,
766 $this->form,
767 $settings
768 );
769
770 if (!$failClosed) {
771 return;
772 }
773
774 $default = __('Sorry! We could not verify your location, so this form cannot be submitted right now.', 'fluentform');
775
776 self::throwValidationException(
777 apply_filters('fluentform/country_unresolved_message', $default, $this->form)
778 );
779 }
780
781 /**
782 * Resolve the visitor country, trying each provider in turn.
783 *
784 * A geo provider can only answer for a routable address; for a private or
785 * reserved one ipinfo.io replies {"bogon":true} with no country and apip.cc
786 * replies status:fail. resolveIp() yields such an address for CLI and cron
787 * submissions, for an unparseable REMOTE_ADDR, and on a site whose reverse
788 * proxy sits on a private network. Skipping the lookups there reaches the
789 * same answer without two blocking HTTP timeouts.
790 *
791 * @return string|null
792 */
793 private function resolveCountryFromIp($ip)
794 {
795 if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
796 return Helper::getCountryCodeFromHeaders(true);
797 }
798
799 $cached = self::cachedCountry($ip);
800
801 if (false !== $cached) {
802 return 'none' === $cached ? null : $cached;
803 }
804
805 $country = null;
806
807 if ($ipInfo = $this->getIpInfo($ip)) {
808 $country = self::normalizeCountry(Arr::get($ipInfo, 'country'));
809 }
810
811 $answered = null !== $country;
812
813 if (!$country) {
814 if (get_transient('fluentform_geo_apip_backoff')) {
815 // Nothing was asked, so there is no verdict to remember. Caching
816 // here would outlive the back-off and pin the miss indefinitely.
817 return Helper::getCountryCodeFromHeaders(true);
818 }
819
820 $country = $this->getIpBasedOnCountry($ip, $answered);
821 }
822
823 if ($answered) {
824 self::cacheCountry($ip, $country);
825 }
826
827 return $country;
828 }
829
830 /**
831 * Providers are third parties; only a real ISO 3166-1 alpha-2 code may
832 * reach enforcement or the cache.
833 *
834 * @param mixed $country
835 * @return string|null
836 */
837 private static function normalizeCountry($country)
838 {
839 if (!is_string($country)) {
840 return null;
841 }
842
843 $country = strtoupper(trim($country));
844
845 return preg_match('/^[A-Z]{2}$/', $country) ? $country : null;
846 }
847
848 /**
849 * @param string $ip
850 * @return string|false 'none' for a cached miss, false when not cached
851 */
852 private static function cachedCountry($ip)
853 {
854 $shard = get_transient(self::cacheShardKey($ip));
855
856 if (!is_array($shard)) {
857 return false;
858 }
859
860 $key = md5($ip);
861
862 if (!isset($shard[$key]['c'], $shard[$key]['t'])) {
863 return false;
864 }
865
866 // Each entry carries its own stamp because the row's TTL is pushed
867 // forward by every write, so on a busy form the row never expires.
868 if ((time() - (int) $shard[$key]['t']) > self::GEO_CACHE_MINUTES * MINUTE_IN_SECONDS) {
869 return false;
870 }
871
872 return $shard[$key]['c'];
873 }
874
875 /**
876 * Sharded so the rows stay small and concurrent submissions rarely collide
877 * on the same read-modify-write, and bounded so a flood of unique addresses
878 * cannot grow wp_options without limit. Addresses are hashed: this store is
879 * not submission data and must not become an IP log.
880 *
881 * @param string $ip
882 * @param string|null $country
883 * @return void
884 */
885 private static function cacheCountry($ip, $country)
886 {
887 $shardKey = self::cacheShardKey($ip);
888 $shard = get_transient($shardKey);
889 $shard = is_array($shard) ? $shard : [];
890
891 $key = md5($ip);
892
893 unset($shard[$key]);
894 $shard[$key] = ['c' => $country ?: 'none', 't' => time()];
895
896 if (count($shard) > self::GEO_CACHE_SHARD_MAX) {
897 $shard = array_slice($shard, -self::GEO_CACHE_SHARD_MAX, null, true);
898 }
899
900 set_transient($shardKey, $shard, self::GEO_CACHE_MINUTES * MINUTE_IN_SECONDS);
901 }
902
903 /**
904 * @param string $ip
905 * @return string
906 */
907 private static function cacheShardKey($ip)
908 {
909 return 'fluentform_geo_country_' . substr(md5($ip), 0, 2);
910 }
911
912 /**
913 * Count an inconclusive answer, and park the provider once they repeat.
914 *
915 * A single timeout or unusable body says nothing about the provider's
916 * health for other visitors, so it must not disable enforcement for them;
917 * a run of them does.
918 *
919 * @param string $provider
920 * @return void
921 */
922 private static function recordProviderStrike($provider)
923 {
924 $key = 'fluentform_geo_' . $provider . '_strikes';
925 $strikes = (int) get_transient($key) + 1;
926
927 if ($strikes >= self::GEO_PROVIDER_STRIKES) {
928 delete_transient($key);
929 self::backOffProvider($provider);
930
931 return;
932 }
933
934 set_transient($key, $strikes, self::GEO_BACKOFF_MINUTES * MINUTE_IN_SECONDS);
935 }
936
937 /**
938 * Whether a status code says the provider is unusable for everyone, rather
939 * than just for the address being looked up.
940 *
941 * Parking a provider is global, so only a provider-wide fault may do it:
942 * rejected credentials, exhausted quota, or the provider being down. A
943 * per-address oddity must never disable enforcement for other visitors.
944 *
945 * @param int|string $code
946 * @return bool
947 */
948 private static function isProviderWideFailure($code)
949 {
950 $code = (int) $code;
951
952 return in_array($code, [401, 403, 429], true) || $code >= 500;
953 }
954
955 /**
956 * Park a provider that just failed, so it is not re-asked per submission.
957 *
958 * @param string $provider
959 * @return void
960 */
961 private static function backOffProvider($provider)
962 {
963 set_transient(
964 'fluentform_geo_' . $provider . '_backoff',
965 1,
966 self::GEO_BACKOFF_MINUTES * MINUTE_IN_SECONDS
967 );
968 }
969
970 /**
971 * Get IP info from ipinfo.io
972 *
973 * Returns false on any failure - rejected token, outage, malformed body -
974 * so the caller falls through to apip.cc and then to the request headers.
975 * A misconfigured token is an admin error; it must not cancel every
976 * visitor's submission.
977 *
978 * @return array|false
979 */
980 private function getIpInfo($ip) {
981 $token = Helper::getIpinfo();
982
983 if (!$token || get_transient('fluentform_geo_ipinfo_backoff')) {
984 return false;
985 }
986
987 // Bearer, not a query parameter: a credential in a URL is logged by
988 // every outbound proxy the request passes through.
989 $data = wp_remote_get('https://ipinfo.io/' . rawurlencode($ip), [
990 'timeout' => self::GEO_TIMEOUT,
991 'headers' => ['Authorization' => 'Bearer ' . $token],
992 ]);
993
994 if (is_wp_error($data)) {
995 self::recordProviderStrike('ipinfo');
996
997 return false;
998 }
999
1000 $code = wp_remote_retrieve_response_code($data);
1001
1002 if (200 !== $code) {
1003 if (self::isProviderWideFailure($code)) {
1004 self::backOffProvider('ipinfo');
1005 }
1006
1007 return false;
1008 }
1009
1010 $result = \json_decode(wp_remote_retrieve_body($data), true);
1011
1012 // Same reasoning as apip.cc below: a body we cannot use is about this
1013 // address, not the provider's health, so it must not count globally.
1014 if (!is_array($result)) {
1015 return false;
1016 }
1017
1018 delete_transient('fluentform_geo_ipinfo_strikes');
1019
1020 return $result;
1021 }
1022
1023 /**
1024 * Get IP and Country from apip.cc, falling back to the request headers.
1025 *
1026 * @return string|null
1027 */
1028 private function getIpBasedOnCountry($ip, &$answered = false) {
1029 if (get_transient('fluentform_geo_apip_backoff')) {
1030 return Helper::getCountryCodeFromHeaders(true);
1031 }
1032
1033 $request = wp_remote_get(
1034 'https://apip.cc/api-json/' . rawurlencode($ip),
1035 ['timeout' => self::GEO_TIMEOUT]
1036 );
1037
1038 if (is_wp_error($request)) {
1039 self::recordProviderStrike('apip');
1040
1041 return Helper::getCountryCodeFromHeaders(true);
1042 }
1043
1044 $code = wp_remote_retrieve_response_code($request);
1045
1046 if (200 !== $code) {
1047 if (self::isProviderWideFailure($code)) {
1048 self::backOffProvider('apip');
1049 }
1050
1051 // FINDING-26: the provider gave us nothing. Return the CDN header only
1052 // if the site opted into trusting it for enforcement; otherwise null,
1053 // which hands the decision to handleUnresolvedCountry().
1054 return Helper::getCountryCodeFromHeaders(true);
1055 }
1056
1057 // The provider answered about this address, so the result is a verdict
1058 // worth remembering even when it is "no country".
1059 $answered = true;
1060
1061 $body = \json_decode(wp_remote_retrieve_body($request), true);
1062 $country = self::normalizeCountry(Arr::get((array) $body, 'CountryCode'));
1063
1064 if ('success' === Arr::get((array) $body, 'status') && $country) {
1065 delete_transient('fluentform_geo_apip_strikes');
1066
1067 return $country;
1068 }
1069
1070 // No strike here. A 200 that carries no usable country is an answer about
1071 // this address, and which address is looked up is chosen by whoever
1072 // submits - letting it count towards a global park would hand a remote
1073 // submitter a way to disable the provider for everyone. The miss is
1074 // cached against this address instead, which is what stops it being
1075 // re-asked on the next submission.
1076 return Helper::getCountryCodeFromHeaders(true);
1077 }
1078
1079 /**
1080 * @param $value
1081 * @param $providedKeywords
1082 * @return bool
1083 */
1084 public static function containsRestrictedKeywords($value, $providedKeywords) {
1085 $value = (string) $value;
1086 if ('' === $value) {
1087 return false;
1088 }
1089
1090 foreach ((array) $providedKeywords as $keyword) {
1091 $keyword = (string) $keyword;
1092 if ('' === $keyword || self::isUnusableKeyword($keyword)) {
1093 continue;
1094 }
1095
1096 if (preg_match(self::keywordPattern($keyword), $value)) {
1097 return true;
1098 }
1099 }
1100
1101 return false;
1102 }
1103
1104 /**
1105 * A lone punctuation mark or invisible format character is never a usable
1106 * restriction keyword.
1107 *
1108 * The previous implementation stripped these before matching, so an entry
1109 * like "." or a stray zero-width space sat in a site's keyword list doing
1110 * nothing at all. Now that keywords match on the raw value, such an entry
1111 * would hit almost every submission and silently reject the whole form —
1112 * and an invisible one (ZWSP, soft hyphen, BOM, picked up by pasting a list
1113 * from a document) could never be spotted in the settings field. Skipping
1114 * them protects sites carrying a stray entry without costing anything that
1115 * ever worked: every character in these two categories was already inert.
1116 *
1117 * Deliberately NOT skipped: spaces (\p{Zs}) and tabs/newlines (\p{Cc}) did
1118 * match under the old tokenizer, so they must keep matching. Currency, math,
1119 * arrows, emoji and any multi-character keyword ("$$$", "http://") are
1120 * unaffected — only single characters are considered here.
1121 *
1122 * @param string $keyword
1123 * @return bool
1124 */
1125 private static function isUnusableKeyword($keyword)
1126 {
1127 return 1 === mb_strlen($keyword, 'UTF-8') && preg_match('/^[\p{P}\p{Cf}]$/u', $keyword);
1128 }
1129
1130 /**
1131 * Build the whole-word matcher for a single restricted keyword.
1132 *
1133 * Matching stays whole-word (the keyword glued inside a longer word is not a
1134 * match), but "word" has to be defined per script rather than by PCRE's \b:
1135 *
1136 * - \b/\w never treat combining marks as word characters, not even under
1137 * (*UCP). Indic scripts write vowels and the virama as marks, so "বাংলা"
1138 * (ব + া + ং + ল + া) has no trailing boundary and could never match.
1139 * \p{M} is therefore part of the word class.
1140 * - Han, Kana, Thai, Lao, Khmer, Myanmar and Tibetan don't separate words at
1141 * all, so no boundary can ever exist around a keyword. Whole-word is
1142 * meaningless there and the keyword is matched as a substring instead.
1143 *
1144 * The neighbouring-character guard covers base letters and digits. Marks
1145 * that are part of the keyword remain in the quoted literal, while a mark
1146 * appended after a keyword cannot turn into a bypass. Everything else —
1147 * underscore, zero-width joiners and punctuation — stays a separator,
1148 * matching the class the previous implementation tokenised on.
1149 *
1150 * @param string $keyword
1151 * @return string
1152 */
1153 private static function keywordPattern($keyword)
1154 {
1155 $quoted = preg_quote($keyword, '/');
1156
1157 if (preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Lao}\p{Khmer}\p{Myanmar}\p{Tibetan}]/u', $keyword)) {
1158 return '/' . $quoted . '/ui';
1159 }
1160
1161 $edgeChar = '\p{L}\p{M}\d';
1162 $neighborChar = '\p{L}\d';
1163
1164 // Only guard an edge that is itself a word character, so keywords
1165 // wrapped in punctuation (e.g. "$$$" or "buy!") stay matchable.
1166 $lead = preg_match('/^[' . $edgeChar . ']/u', $keyword) ? '(?<![' . $neighborChar . '])' : '';
1167 $trail = preg_match('/[' . $edgeChar . ']$/u', $keyword) ? '(?![' . $neighborChar . '])' : '';
1168
1169 return '/' . $lead . $quoted . $trail . '/ui';
1170 }
1171
1172
1173 /**
1174 * @throws ValidationException
1175 */
1176 private function checkIpRestriction($settings, $ip)
1177 {
1178 if (Arr::isTrue($settings, 'fields.ip.status') && $ip) {
1179 $providedIp = array_map('trim', explode(',', (string) Arr::get($settings, 'fields.ip.values', '')));
1180
1181 $isFailed = Arr::get($settings, 'fields.ip.validation_type') === 'fail_on_condition_met';
1182
1183 $failedSubmissionIfExists = $isFailed && in_array($ip, $providedIp);
1184 $allowSubmissionIfNotExists = !$isFailed && !in_array($ip, $providedIp);
1185
1186 if ($failedSubmissionIfExists || $allowSubmissionIfNotExists) {
1187 $defaultMessage = __('Sorry! You can\'t submit a form from your IP address.', 'fluentform');
1188 $message = apply_filters('fluentform/ip_restriction_message', Arr::get($settings, 'fields.ip.message', $defaultMessage), $this->form);
1189 self::throwValidationException($message);
1190 }
1191 }
1192 }
1193
1194 /**
1195 * @throws ValidationException
1196 */
1197 private function checkCountryRestriction($settings, $country)
1198 {
1199 if (Arr::isTrue($settings, 'fields.country.status') && $country) {
1200 $providedCountry = (array) Arr::get($settings, 'fields.country.values', []);
1201
1202 $isFailed = Arr::get($settings, 'fields.country.validation_type') === 'fail_on_condition_met';
1203
1204 $failedSubmissionIfExists = $isFailed && in_array($country, $providedCountry);
1205 $allowSubmissionIfNotExists = !$isFailed && !in_array($country, $providedCountry);
1206
1207 if ($failedSubmissionIfExists || $allowSubmissionIfNotExists) {
1208 $defaultMessage = __('Sorry! You can\'t submit this form from the country you are residing.', 'fluentform');
1209 $message = apply_filters('fluentform/country_restriction_message', Arr::get($settings, 'fields.country.message', $defaultMessage), $this->form);
1210 self::throwValidationException($message);
1211 }
1212 }
1213 }
1214
1215 private function checkKeyWordRestriction($settings)
1216 {
1217 if (!Arr::isTrue($settings, 'fields.keywords.status')) {
1218 return;
1219 }
1220
1221 $keywords = Arr::get($settings, 'fields.keywords.values');
1222 if (!$keywords || !is_string($keywords)) {
1223 return;
1224 }
1225 $providedKeywords = explode(',', $keywords);
1226 $providedKeywords = array_filter(array_map('trim', $providedKeywords));
1227 if (!$providedKeywords) {
1228 return;
1229 }
1230 $inputSubmission = array_intersect_key(
1231 $this->formData,
1232 array_flip(
1233 array_keys(
1234 FormFieldsParser::getInputs($this->form)
1235 )
1236 )
1237 );
1238 $defaultMessage = __('Sorry! Your submission contains some restricted keywords.', 'fluentform');
1239 $message = apply_filters('fluentform/keyword_restriction_message', Arr::get($settings, 'fields.keywords.message', $defaultMessage), $this->form);
1240
1241 self::checkKeywordsMatching($inputSubmission, $message, $providedKeywords);
1242 }
1243
1244 private static function checkKeywordsMatching($inputSubmission, $message, $providedKeywords)
1245 {
1246 foreach ($inputSubmission as $value) {
1247 if (!empty($value)) {
1248 if (is_array($value)) {
1249 self::checkKeywordsMatching($value, $message, $providedKeywords);
1250 } else {
1251 if (self::containsRestrictedKeywords($value, $providedKeywords)) {
1252 self::throwValidationException($message);
1253 }
1254 }
1255 }
1256 }
1257 }
1258
1259 /**
1260 * @throws ValidationException
1261 */
1262 public static function throwValidationException($message) {
1263 throw new ValidationException('', 422, null, [
1264 'errors' => [
1265 'restricted' => [
1266 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Sanitized by fluentform_sanitize_html
1267 fluentform_sanitize_html($message)
1268 ],
1269 ],
1270 ]);
1271 }
1272
1273 /**
1274 * Check if captcha validation should be skipped based on autoload captcha settings
1275 *
1276 * When autoload captcha is enabled, only the selected captcha type should be validated.
1277 * This method returns true if the current captcha type is NOT the selected autoload type,
1278 * preventing unnecessary validation of multiple captcha types on the same form.
1279 *
1280 * @param string $captchaType The captcha type to check ('recaptcha', 'hcaptcha', 'turnstile')
1281 * @return bool True if validation should be skipped, false otherwise
1282 */
1283 private function shouldSkipCaptchaValidation($captchaType)
1284 {
1285 $globalSettings = get_option('_fluentform_global_form_settings');
1286 $autoloadEnabled = Arr::get($globalSettings, 'misc.autoload_captcha');
1287
1288 // If autoload captcha is not enabled, don't skip any validation
1289 if (!$autoloadEnabled) {
1290 return false;
1291 }
1292
1293 $selectedCaptchaType = Arr::get($globalSettings, 'misc.captcha_type');
1294
1295 // If the current captcha type matches the selected autoload type, proceed with validation
1296 if ($captchaType === $selectedCaptchaType) {
1297 return false;
1298 }
1299
1300 return true; // Skip validation for non-selected captcha types
1301 }
1302 }
1303