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 / Services / Form / FormValidationService.php

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

1,297 lines 47.5 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 '_fluentformnonce' => [
432 __('Nonce verification failed, please try again.', 'fluentform'),
433 ],
434 ],
435 FLUENTFORM_FRAMEWORK_UPGRADE,
436 'fluentForm/nonce_error',
437 'Use fluentForm/nonce_error instead of fluentForm_nonce_error.'
438 );
439
440 $errors = $this->app->applyFilters('fluentForm/nonce_error', $errors);
441 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
442 throw new ValidationException('', 422, null, ['errors' => $errors]);
443 }
444 }
445 }
446
447 /** Validate Akismet Spam
448 * @throws ValidationException
449 */
450 public function handleAkismetSpamError()
451 {
452 $settings = get_option('_fluentform_global_form_settings');
453 if (!$settings || 'validation_failed' != Arr::get($settings, 'misc.akismet_validation')) {
454 return;
455 }
456
457 $errors = [
458 '_fluentformakismet' => __('Submission marked as spammed. Please try again', 'fluentform'),
459 ];
460 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
461 throw new ValidationException('', 422, null, ['errors' => $errors]);
462 }
463
464 /** Validate CleanTalk Spam
465 * @throws ValidationException
466 */
467 public function handleCleanTalkSpamError()
468 {
469 $settings = get_option('_fluentform_global_form_settings');
470 if (!$settings || 'validation_failed' != Arr::get($settings, 'misc.cleantalk_validation')) {
471 return;
472 }
473
474 $errors = [
475 '_fluentformcleantalk' => __('Submission marked as spammed. Please try again', 'fluentform'),
476 ];
477 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
478 throw new ValidationException('', 422, null, ['errors' => $errors]);
479 }
480
481 /** Validate CleanTalk Spam While Using API
482 * @throws ValidationException
483 */
484 public function handleCleanTalkSpamErrorUsingAPi()
485 {
486 $cleantalkSettings = get_option('_fluentform_cleantalk_details');
487
488 if (
489 !$cleantalkSettings ||
490 'validation_failed' != Arr::get($cleantalkSettings, 'validation')
491 ) {
492 return;
493 }
494
495 $errors = [
496 '_fluentformcleantalk' => __('Submission marked as spammed. Please try again', 'fluentform'),
497 ];
498 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
499 throw new ValidationException('', 422, null, ['errors' => $errors]);
500 }
501
502 public function isAkismetSpam($formData, $form)
503 {
504 if (!AkismetHandler::isEnabled()) {
505 return false;
506 }
507 $isSpamCheck = apply_filters_deprecated(
508 'fluentform_akismet_check_spam',
509 [
510 true,
511 $form->id,
512 $formData
513 ],
514 FLUENTFORM_FRAMEWORK_UPGRADE,
515 'fluentform/akismet_check_spam',
516 'Use fluentform/akismet_check_spam instead of fluentform_akismet_check_spam.'
517 );
518
519 $isSpamCheck = apply_filters('fluentform/akismet_check_spam', $isSpamCheck, $form->id, $formData);
520
521 if (!$isSpamCheck) {
522 return false;
523 }
524 // Let's validate now
525 $isSpam = AkismetHandler::isSpamSubmission($formData, $form);
526
527 $isSpam = apply_filters_deprecated(
528 'fluentform_akismet_spam_result',
529 [
530 $isSpam,
531 $form->id,
532 $formData
533 ],
534 FLUENTFORM_FRAMEWORK_UPGRADE,
535 'fluentform/akismet_spam_result',
536 'Use fluentform/akismet_spam_result instead of fluentform_akismet_spam_result.'
537 );
538 return apply_filters('fluentform/akismet_spam_result', $isSpam, $form->id, $formData);
539 }
540
541 public function isCleanTalkSpam($formData, $form)
542 {
543 if (!CleanTalkHandler::isEnabled()) {
544 return false;
545 }
546 $isSpamCheck = apply_filters('fluentform/cleantalk_check_spam', true, $form->id, $formData);
547
548 if (!$isSpamCheck) {
549 return false;
550 }
551 $isSpam = CleanTalkHandler::isSpamSubmission($formData, $form);
552
553 return apply_filters('fluentform/cleantalk_spam_result', $isSpam, $form->id, $formData);
554 }
555
556 public function isCleanTalkSpamUsingApi($formData, $form)
557 {
558 if (!CleanTalkHandler::isCleantalkActivated()) {
559 return false;
560 }
561
562 $isSpamCheck = apply_filters('fluentform/cleantalk_check_spam', true, $form->id, $formData);
563
564 if (!$isSpamCheck) {
565 return false;
566 }
567
568 $isSpam = CleanTalkHandler::spamSubmissionCheckWithApi($formData, $form);
569
570 return apply_filters('fluentform/cleantalk_spam_result', $isSpam, $form->id, $formData);
571 }
572
573 /**
574 * Validate reCaptcha.
575 * Uses 'fluentform/disable_captcha' filter with 'recaptcha' as the captcha type since 6.0.3
576 * @throws ValidationException
577 */
578 private function validateReCaptcha()
579 {
580 // Check if autoload_captcha is enabled and if it's not recaptcha, skip validation
581 if ($this->shouldSkipCaptchaValidation('recaptcha')) {
582 return;
583 }
584
585 $hasAutoRecap = apply_filters_deprecated(
586 'ff_has_auto_recaptcha',
587 [
588 false
589 ],
590 FLUENTFORM_FRAMEWORK_UPGRADE,
591 'fluentform/has_recaptcha',
592 'Use fluentform/has_recaptcha instead of ff_has_auto_recaptcha.'
593 );
594 $autoInclude = apply_filters('fluentform/has_recaptcha', $hasAutoRecap);
595 $disableReCaptcha = apply_filters('fluentform/disable_captcha', false, $this->form, 'recaptcha');
596
597 if (!$disableReCaptcha && (FormFieldsParser::hasElement($this->form, 'recaptcha') || $autoInclude)) {
598 $keys = get_option('_fluentform_reCaptcha_details');
599 $token = Arr::get($this->formData, 'g-recaptcha-response');
600 $version = 'v2_visible';
601 if (!empty($keys['api_version'])) {
602 $version = $keys['api_version'];
603 }
604 $isValid = ReCaptcha::validate($token, $keys['secretKey'], $version);
605
606 if (!$isValid) {
607 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
608 throw new ValidationException('', 422, null, [
609 'errors' => [
610 'g-recaptcha-response' => [
611 esc_html(__('reCaptcha verification failed, please try again.', 'fluentform')),
612 ],
613 ],
614 ]);
615 }
616 }
617 }
618
619 /**
620 * Validate hCaptcha.
621 *
622 * @throws ValidationException
623 */
624 private function validateHCaptcha()
625 {
626 // Check if autoload_captcha is enabled and if it's not hcaptcha, skip validation
627 if ($this->shouldSkipCaptchaValidation('hcaptcha')) {
628 return;
629 }
630 $hasAutoHcap = apply_filters_deprecated(
631 'ff_has_auto_hcaptcha',
632 [
633 false
634 ],
635 FLUENTFORM_FRAMEWORK_UPGRADE,
636 'fluentform/has_hcaptcha',
637 'Use fluentform/has_hcaptcha instead of ff_has_auto_hcaptcha.'
638 );
639 $autoInclude = apply_filters('fluentform/has_hcaptcha', $hasAutoHcap);
640 $disableHCaptcha = apply_filters('fluentform/disable_captcha', false, $this->form, 'hcaptcha');
641
642 FormFieldsParser::resetData();
643 if (!$disableHCaptcha && (FormFieldsParser::hasElement($this->form, 'hcaptcha') || $autoInclude)) {
644 $keys = get_option('_fluentform_hCaptcha_details');
645 $token = Arr::get($this->formData, 'h-captcha-response');
646 $isValid = HCaptcha::validate($token, $keys['secretKey']);
647
648 if (!$isValid) {
649 throw new ValidationException('', 422, null, [
650 'errors' => [
651 'h-captcha-response' => [
652 esc_html(__('hCaptcha verification failed, please try again.', 'fluentform')),
653 ],
654 ],
655 ]);
656 }
657 }
658 }
659
660 /**
661 * Validate turnstile.
662 *
663 * @throws ValidationException
664 */
665 private function validateTurnstile()
666 {
667 // Check if autoload_captcha is enabled and if it's not turnstile, skip validation
668 if ($this->shouldSkipCaptchaValidation('turnstile')) {
669 return;
670 }
671
672 $hasAutoTurnsTile = apply_filters_deprecated(
673 'ff_has_auto_turnstile',
674 [
675 false
676 ],
677 FLUENTFORM_FRAMEWORK_UPGRADE,
678 'fluentform/has_turnstile',
679 'Use fluentform/has_turnstile instead of ff_has_auto_turnstile.'
680 );
681 $autoInclude = apply_filters('fluentform/has_turnstile', $hasAutoTurnsTile);
682 $disableTurnsTile = apply_filters('fluentform/disable_captcha', false, $this->form, 'turnstile');
683
684 if (!$disableTurnsTile && (FormFieldsParser::hasElement($this->form, 'turnstile') || $autoInclude)) {
685 $keys = get_option('_fluentform_turnstile_details');
686 $token = Arr::get($this->formData, 'cf-turnstile-response');
687
688 $isValid = Turnstile::validate($token, $keys['secretKey']);
689
690 if (!$isValid) {
691 throw new ValidationException('', 422, null, [
692 'errors' => [
693 'cf-turnstile-response' => [
694 esc_html(__('Turnstile verification failed, please try again.', 'fluentform')),
695 ],
696 ],
697 ]);
698 }
699 }
700 }
701
702
703 /**
704 * Delegate the validation rules & messages to the
705 * ones that the validation library recognizes.
706 *
707 * @param $rules
708 * @param $messages
709 * @param array $search
710 * @param array $replace
711 * @return array
712 */
713 protected function delegateValidations($rules, $messages, $search = [], $replace = [])
714 {
715 $search = $search ?: ['max_file_size', 'allowed_file_types'];
716 $replace = $replace ?: ['max', 'mimes'];
717
718 foreach ($rules as &$rule) {
719 $rule = str_replace($search, $replace, $rule);
720 }
721
722 foreach ($messages as $key => $message) {
723 $newKey = str_replace($search, $replace, $key);
724 $messages[$newKey] = $message;
725 unset($messages[$key]);
726 }
727
728 return [$rules, $messages];
729 }
730
731 /**
732 * Decide what an unresolved country means for this rule.
733 *
734 * A block list stays permissive: the providers are third party, and their
735 * outage must not stop a site taking submissions. An allow list cannot be
736 * honoured at all without a country - letting it through would turn "only
737 * these countries" into "anyone" - so it fails closed. Either case can be
738 * inverted with the filter.
739 *
740 * @throws ValidationException
741 */
742 private function handleUnresolvedCountry($settings)
743 {
744 // A rule with no countries chosen cannot express an intent, so it must
745 // not acquire a brand new way to reject people.
746 if (!array_filter((array) Arr::get($settings, 'fields.country.values', []))) {
747 return;
748 }
749
750 // Derived negatively on purpose: checkCountryRestriction() treats
751 // anything that is not fail_on_condition_met as an allow list, and a
752 // form saved before validation_type existed has the key absent. Testing
753 // for the allow-list string instead would leave those forms enforced as
754 // an allow list while being failed open as a block list.
755 $isAllowList = 'fail_on_condition_met' !== Arr::get($settings, 'fields.country.validation_type');
756
757 $failClosed = apply_filters(
758 'fluentform/country_restriction_fail_closed',
759 $isAllowList,
760 $this->form,
761 $settings
762 );
763
764 if (!$failClosed) {
765 return;
766 }
767
768 $default = __('Sorry! We could not verify your location, so this form cannot be submitted right now.', 'fluentform');
769
770 self::throwValidationException(
771 apply_filters('fluentform/country_unresolved_message', $default, $this->form)
772 );
773 }
774
775 /**
776 * Resolve the visitor country, trying each provider in turn.
777 *
778 * A geo provider can only answer for a routable address; for a private or
779 * reserved one ipinfo.io replies {"bogon":true} with no country and apip.cc
780 * replies status:fail. resolveIp() yields such an address for CLI and cron
781 * submissions, for an unparseable REMOTE_ADDR, and on a site whose reverse
782 * proxy sits on a private network. Skipping the lookups there reaches the
783 * same answer without two blocking HTTP timeouts.
784 *
785 * @return string|null
786 */
787 private function resolveCountryFromIp($ip)
788 {
789 if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
790 return Helper::getCountryCodeFromHeaders(true);
791 }
792
793 $cached = self::cachedCountry($ip);
794
795 if (false !== $cached) {
796 return 'none' === $cached ? null : $cached;
797 }
798
799 $country = null;
800
801 if ($ipInfo = $this->getIpInfo($ip)) {
802 $country = self::normalizeCountry(Arr::get($ipInfo, 'country'));
803 }
804
805 $answered = null !== $country;
806
807 if (!$country) {
808 if (get_transient('fluentform_geo_apip_backoff')) {
809 // Nothing was asked, so there is no verdict to remember. Caching
810 // here would outlive the back-off and pin the miss indefinitely.
811 return Helper::getCountryCodeFromHeaders(true);
812 }
813
814 $country = $this->getIpBasedOnCountry($ip, $answered);
815 }
816
817 if ($answered) {
818 self::cacheCountry($ip, $country);
819 }
820
821 return $country;
822 }
823
824 /**
825 * Providers are third parties; only a real ISO 3166-1 alpha-2 code may
826 * reach enforcement or the cache.
827 *
828 * @param mixed $country
829 * @return string|null
830 */
831 private static function normalizeCountry($country)
832 {
833 if (!is_string($country)) {
834 return null;
835 }
836
837 $country = strtoupper(trim($country));
838
839 return preg_match('/^[A-Z]{2}$/', $country) ? $country : null;
840 }
841
842 /**
843 * @param string $ip
844 * @return string|false 'none' for a cached miss, false when not cached
845 */
846 private static function cachedCountry($ip)
847 {
848 $shard = get_transient(self::cacheShardKey($ip));
849
850 if (!is_array($shard)) {
851 return false;
852 }
853
854 $key = md5($ip);
855
856 if (!isset($shard[$key]['c'], $shard[$key]['t'])) {
857 return false;
858 }
859
860 // Each entry carries its own stamp because the row's TTL is pushed
861 // forward by every write, so on a busy form the row never expires.
862 if ((time() - (int) $shard[$key]['t']) > self::GEO_CACHE_MINUTES * MINUTE_IN_SECONDS) {
863 return false;
864 }
865
866 return $shard[$key]['c'];
867 }
868
869 /**
870 * Sharded so the rows stay small and concurrent submissions rarely collide
871 * on the same read-modify-write, and bounded so a flood of unique addresses
872 * cannot grow wp_options without limit. Addresses are hashed: this store is
873 * not submission data and must not become an IP log.
874 *
875 * @param string $ip
876 * @param string|null $country
877 * @return void
878 */
879 private static function cacheCountry($ip, $country)
880 {
881 $shardKey = self::cacheShardKey($ip);
882 $shard = get_transient($shardKey);
883 $shard = is_array($shard) ? $shard : [];
884
885 $key = md5($ip);
886
887 unset($shard[$key]);
888 $shard[$key] = ['c' => $country ?: 'none', 't' => time()];
889
890 if (count($shard) > self::GEO_CACHE_SHARD_MAX) {
891 $shard = array_slice($shard, -self::GEO_CACHE_SHARD_MAX, null, true);
892 }
893
894 set_transient($shardKey, $shard, self::GEO_CACHE_MINUTES * MINUTE_IN_SECONDS);
895 }
896
897 /**
898 * @param string $ip
899 * @return string
900 */
901 private static function cacheShardKey($ip)
902 {
903 return 'fluentform_geo_country_' . substr(md5($ip), 0, 2);
904 }
905
906 /**
907 * Count an inconclusive answer, and park the provider once they repeat.
908 *
909 * A single timeout or unusable body says nothing about the provider's
910 * health for other visitors, so it must not disable enforcement for them;
911 * a run of them does.
912 *
913 * @param string $provider
914 * @return void
915 */
916 private static function recordProviderStrike($provider)
917 {
918 $key = 'fluentform_geo_' . $provider . '_strikes';
919 $strikes = (int) get_transient($key) + 1;
920
921 if ($strikes >= self::GEO_PROVIDER_STRIKES) {
922 delete_transient($key);
923 self::backOffProvider($provider);
924
925 return;
926 }
927
928 set_transient($key, $strikes, self::GEO_BACKOFF_MINUTES * MINUTE_IN_SECONDS);
929 }
930
931 /**
932 * Whether a status code says the provider is unusable for everyone, rather
933 * than just for the address being looked up.
934 *
935 * Parking a provider is global, so only a provider-wide fault may do it:
936 * rejected credentials, exhausted quota, or the provider being down. A
937 * per-address oddity must never disable enforcement for other visitors.
938 *
939 * @param int|string $code
940 * @return bool
941 */
942 private static function isProviderWideFailure($code)
943 {
944 $code = (int) $code;
945
946 return in_array($code, [401, 403, 429], true) || $code >= 500;
947 }
948
949 /**
950 * Park a provider that just failed, so it is not re-asked per submission.
951 *
952 * @param string $provider
953 * @return void
954 */
955 private static function backOffProvider($provider)
956 {
957 set_transient(
958 'fluentform_geo_' . $provider . '_backoff',
959 1,
960 self::GEO_BACKOFF_MINUTES * MINUTE_IN_SECONDS
961 );
962 }
963
964 /**
965 * Get IP info from ipinfo.io
966 *
967 * Returns false on any failure - rejected token, outage, malformed body -
968 * so the caller falls through to apip.cc and then to the request headers.
969 * A misconfigured token is an admin error; it must not cancel every
970 * visitor's submission.
971 *
972 * @return array|false
973 */
974 private function getIpInfo($ip) {
975 $token = Helper::getIpinfo();
976
977 if (!$token || get_transient('fluentform_geo_ipinfo_backoff')) {
978 return false;
979 }
980
981 // Bearer, not a query parameter: a credential in a URL is logged by
982 // every outbound proxy the request passes through.
983 $data = wp_remote_get('https://ipinfo.io/' . rawurlencode($ip), [
984 'timeout' => self::GEO_TIMEOUT,
985 'headers' => ['Authorization' => 'Bearer ' . $token],
986 ]);
987
988 if (is_wp_error($data)) {
989 self::recordProviderStrike('ipinfo');
990
991 return false;
992 }
993
994 $code = wp_remote_retrieve_response_code($data);
995
996 if (200 !== $code) {
997 if (self::isProviderWideFailure($code)) {
998 self::backOffProvider('ipinfo');
999 }
1000
1001 return false;
1002 }
1003
1004 $result = \json_decode(wp_remote_retrieve_body($data), true);
1005
1006 // Same reasoning as apip.cc below: a body we cannot use is about this
1007 // address, not the provider's health, so it must not count globally.
1008 if (!is_array($result)) {
1009 return false;
1010 }
1011
1012 delete_transient('fluentform_geo_ipinfo_strikes');
1013
1014 return $result;
1015 }
1016
1017 /**
1018 * Get IP and Country from apip.cc, falling back to the request headers.
1019 *
1020 * @return string|null
1021 */
1022 private function getIpBasedOnCountry($ip, &$answered = false) {
1023 if (get_transient('fluentform_geo_apip_backoff')) {
1024 return Helper::getCountryCodeFromHeaders(true);
1025 }
1026
1027 $request = wp_remote_get(
1028 'https://apip.cc/api-json/' . rawurlencode($ip),
1029 ['timeout' => self::GEO_TIMEOUT]
1030 );
1031
1032 if (is_wp_error($request)) {
1033 self::recordProviderStrike('apip');
1034
1035 return Helper::getCountryCodeFromHeaders(true);
1036 }
1037
1038 $code = wp_remote_retrieve_response_code($request);
1039
1040 if (200 !== $code) {
1041 if (self::isProviderWideFailure($code)) {
1042 self::backOffProvider('apip');
1043 }
1044
1045 // FINDING-26: the provider gave us nothing. Return the CDN header only
1046 // if the site opted into trusting it for enforcement; otherwise null,
1047 // which hands the decision to handleUnresolvedCountry().
1048 return Helper::getCountryCodeFromHeaders(true);
1049 }
1050
1051 // The provider answered about this address, so the result is a verdict
1052 // worth remembering even when it is "no country".
1053 $answered = true;
1054
1055 $body = \json_decode(wp_remote_retrieve_body($request), true);
1056 $country = self::normalizeCountry(Arr::get((array) $body, 'CountryCode'));
1057
1058 if ('success' === Arr::get((array) $body, 'status') && $country) {
1059 delete_transient('fluentform_geo_apip_strikes');
1060
1061 return $country;
1062 }
1063
1064 // No strike here. A 200 that carries no usable country is an answer about
1065 // this address, and which address is looked up is chosen by whoever
1066 // submits - letting it count towards a global park would hand a remote
1067 // submitter a way to disable the provider for everyone. The miss is
1068 // cached against this address instead, which is what stops it being
1069 // re-asked on the next submission.
1070 return Helper::getCountryCodeFromHeaders(true);
1071 }
1072
1073 /**
1074 * @param $value
1075 * @param $providedKeywords
1076 * @return bool
1077 */
1078 public static function containsRestrictedKeywords($value, $providedKeywords) {
1079 $value = (string) $value;
1080 if ('' === $value) {
1081 return false;
1082 }
1083
1084 foreach ((array) $providedKeywords as $keyword) {
1085 $keyword = (string) $keyword;
1086 if ('' === $keyword || self::isUnusableKeyword($keyword)) {
1087 continue;
1088 }
1089
1090 if (preg_match(self::keywordPattern($keyword), $value)) {
1091 return true;
1092 }
1093 }
1094
1095 return false;
1096 }
1097
1098 /**
1099 * A lone punctuation mark or invisible format character is never a usable
1100 * restriction keyword.
1101 *
1102 * The previous implementation stripped these before matching, so an entry
1103 * like "." or a stray zero-width space sat in a site's keyword list doing
1104 * nothing at all. Now that keywords match on the raw value, such an entry
1105 * would hit almost every submission and silently reject the whole form —
1106 * and an invisible one (ZWSP, soft hyphen, BOM, picked up by pasting a list
1107 * from a document) could never be spotted in the settings field. Skipping
1108 * them protects sites carrying a stray entry without costing anything that
1109 * ever worked: every character in these two categories was already inert.
1110 *
1111 * Deliberately NOT skipped: spaces (\p{Zs}) and tabs/newlines (\p{Cc}) did
1112 * match under the old tokenizer, so they must keep matching. Currency, math,
1113 * arrows, emoji and any multi-character keyword ("$$$", "http://") are
1114 * unaffected — only single characters are considered here.
1115 *
1116 * @param string $keyword
1117 * @return bool
1118 */
1119 private static function isUnusableKeyword($keyword)
1120 {
1121 return 1 === mb_strlen($keyword, 'UTF-8') && preg_match('/^[\p{P}\p{Cf}]$/u', $keyword);
1122 }
1123
1124 /**
1125 * Build the whole-word matcher for a single restricted keyword.
1126 *
1127 * Matching stays whole-word (the keyword glued inside a longer word is not a
1128 * match), but "word" has to be defined per script rather than by PCRE's \b:
1129 *
1130 * - \b/\w never treat combining marks as word characters, not even under
1131 * (*UCP). Indic scripts write vowels and the virama as marks, so "বাংলা"
1132 * (ব + া + ং + ল + া) has no trailing boundary and could never match.
1133 * \p{M} is therefore part of the word class.
1134 * - Han, Kana, Thai, Lao, Khmer, Myanmar and Tibetan don't separate words at
1135 * all, so no boundary can ever exist around a keyword. Whole-word is
1136 * meaningless there and the keyword is matched as a substring instead.
1137 *
1138 * The neighbouring-character guard covers base letters and digits. Marks
1139 * that are part of the keyword remain in the quoted literal, while a mark
1140 * appended after a keyword cannot turn into a bypass. Everything else —
1141 * underscore, zero-width joiners and punctuation — stays a separator,
1142 * matching the class the previous implementation tokenised on.
1143 *
1144 * @param string $keyword
1145 * @return string
1146 */
1147 private static function keywordPattern($keyword)
1148 {
1149 $quoted = preg_quote($keyword, '/');
1150
1151 if (preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Lao}\p{Khmer}\p{Myanmar}\p{Tibetan}]/u', $keyword)) {
1152 return '/' . $quoted . '/ui';
1153 }
1154
1155 $edgeChar = '\p{L}\p{M}\d';
1156 $neighborChar = '\p{L}\d';
1157
1158 // Only guard an edge that is itself a word character, so keywords
1159 // wrapped in punctuation (e.g. "$$$" or "buy!") stay matchable.
1160 $lead = preg_match('/^[' . $edgeChar . ']/u', $keyword) ? '(?<![' . $neighborChar . '])' : '';
1161 $trail = preg_match('/[' . $edgeChar . ']$/u', $keyword) ? '(?![' . $neighborChar . '])' : '';
1162
1163 return '/' . $lead . $quoted . $trail . '/ui';
1164 }
1165
1166
1167 /**
1168 * @throws ValidationException
1169 */
1170 private function checkIpRestriction($settings, $ip)
1171 {
1172 if (Arr::isTrue($settings, 'fields.ip.status') && $ip) {
1173 $providedIp = array_map('trim', explode(',', (string) Arr::get($settings, 'fields.ip.values', '')));
1174
1175 $isFailed = Arr::get($settings, 'fields.ip.validation_type') === 'fail_on_condition_met';
1176
1177 $failedSubmissionIfExists = $isFailed && in_array($ip, $providedIp);
1178 $allowSubmissionIfNotExists = !$isFailed && !in_array($ip, $providedIp);
1179
1180 if ($failedSubmissionIfExists || $allowSubmissionIfNotExists) {
1181 $defaultMessage = __('Sorry! You can\'t submit a form from your IP address.', 'fluentform');
1182 $message = apply_filters('fluentform/ip_restriction_message', Arr::get($settings, 'fields.ip.message', $defaultMessage), $this->form);
1183 self::throwValidationException($message);
1184 }
1185 }
1186 }
1187
1188 /**
1189 * @throws ValidationException
1190 */
1191 private function checkCountryRestriction($settings, $country)
1192 {
1193 if (Arr::isTrue($settings, 'fields.country.status') && $country) {
1194 $providedCountry = (array) Arr::get($settings, 'fields.country.values', []);
1195
1196 $isFailed = Arr::get($settings, 'fields.country.validation_type') === 'fail_on_condition_met';
1197
1198 $failedSubmissionIfExists = $isFailed && in_array($country, $providedCountry);
1199 $allowSubmissionIfNotExists = !$isFailed && !in_array($country, $providedCountry);
1200
1201 if ($failedSubmissionIfExists || $allowSubmissionIfNotExists) {
1202 $defaultMessage = __('Sorry! You can\'t submit this form from the country you are residing.', 'fluentform');
1203 $message = apply_filters('fluentform/country_restriction_message', Arr::get($settings, 'fields.country.message', $defaultMessage), $this->form);
1204 self::throwValidationException($message);
1205 }
1206 }
1207 }
1208
1209 private function checkKeyWordRestriction($settings)
1210 {
1211 if (!Arr::isTrue($settings, 'fields.keywords.status')) {
1212 return;
1213 }
1214
1215 $keywords = Arr::get($settings, 'fields.keywords.values');
1216 if (!$keywords || !is_string($keywords)) {
1217 return;
1218 }
1219 $providedKeywords = explode(',', $keywords);
1220 $providedKeywords = array_filter(array_map('trim', $providedKeywords));
1221 if (!$providedKeywords) {
1222 return;
1223 }
1224 $inputSubmission = array_intersect_key(
1225 $this->formData,
1226 array_flip(
1227 array_keys(
1228 FormFieldsParser::getInputs($this->form)
1229 )
1230 )
1231 );
1232 $defaultMessage = __('Sorry! Your submission contains some restricted keywords.', 'fluentform');
1233 $message = apply_filters('fluentform/keyword_restriction_message', Arr::get($settings, 'fields.keywords.message', $defaultMessage), $this->form);
1234
1235 self::checkKeywordsMatching($inputSubmission, $message, $providedKeywords);
1236 }
1237
1238 private static function checkKeywordsMatching($inputSubmission, $message, $providedKeywords)
1239 {
1240 foreach ($inputSubmission as $value) {
1241 if (!empty($value)) {
1242 if (is_array($value)) {
1243 self::checkKeywordsMatching($value, $message, $providedKeywords);
1244 } else {
1245 if (self::containsRestrictedKeywords($value, $providedKeywords)) {
1246 self::throwValidationException($message);
1247 }
1248 }
1249 }
1250 }
1251 }
1252
1253 /**
1254 * @throws ValidationException
1255 */
1256 public static function throwValidationException($message) {
1257 throw new ValidationException('', 422, null, [
1258 'errors' => [
1259 'restricted' => [
1260 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Sanitized by fluentform_sanitize_html
1261 fluentform_sanitize_html($message)
1262 ],
1263 ],
1264 ]);
1265 }
1266
1267 /**
1268 * Check if captcha validation should be skipped based on autoload captcha settings
1269 *
1270 * When autoload captcha is enabled, only the selected captcha type should be validated.
1271 * This method returns true if the current captcha type is NOT the selected autoload type,
1272 * preventing unnecessary validation of multiple captcha types on the same form.
1273 *
1274 * @param string $captchaType The captcha type to check ('recaptcha', 'hcaptcha', 'turnstile')
1275 * @return bool True if validation should be skipped, false otherwise
1276 */
1277 private function shouldSkipCaptchaValidation($captchaType)
1278 {
1279 $globalSettings = get_option('_fluentform_global_form_settings');
1280 $autoloadEnabled = Arr::get($globalSettings, 'misc.autoload_captcha');
1281
1282 // If autoload captcha is not enabled, don't skip any validation
1283 if (!$autoloadEnabled) {
1284 return false;
1285 }
1286
1287 $selectedCaptchaType = Arr::get($globalSettings, 'misc.captcha_type');
1288
1289 // If the current captcha type matches the selected autoload type, proceed with validation
1290 if ($captchaType === $selectedCaptchaType) {
1291 return false;
1292 }
1293
1294 return true; // Skip validation for non-selected captcha types
1295 }
1296 }
1297