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
← All changes | app/Services/Form/FormValidationService.php +440 -53 6.2.7 → 6.2.15 View file →
@@ -17,8 +17,22 @@
17 17 use FluentForm\Framework\Validator\ValidationException;
18 18
19 19 class FormValidationService
20 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 +
21 35 protected $app;
22 36 protected $form;
23 37 protected $formData;
24 38
@@ -130,8 +144,20 @@
130 144 $field['data_key'] = $fieldKey;
131 145 $inputName = Arr::get($field, 'raw.attributes.name');
132 146 $field['name'] = $inputName;
133 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 + }
134 160 $error = apply_filters_deprecated('fluentform_validate_input_item_' . $field['element'], [
135 161 $error,
136 162 $field,
137 163 $formData,
@@ -369,13 +395,14 @@
369 395 }
370 396
371 397 $isCountryRestrictionEnabled = Arr::isTrue($settings, 'fields.country.status');
372 398 if ($isCountryRestrictionEnabled) {
373 - if ($ipInfo = $this->getIpInfo($ip)) {
374 - $country = Arr::get($ipInfo, 'country');
375 - } else {
376 - $country = $this->getIpBasedOnCountry($ip);
399 + $country = $this->resolveCountryFromIp($ip);
400 +
401 + if (!$country) {
402 + $this->handleUnresolvedCountry($settings);
377 403 }
404 +
378 405 $this->checkCountryRestriction($settings, $country);
379 406 }
380 407
381 408 $this->checkKeyWordRestriction($settings);
@@ -400,10 +427,12 @@
400 427 if (!wp_verify_nonce($nonce, 'fluentform-submit-form')) {
401 428 $errors = apply_filters_deprecated(
402 429 'fluentForm_nonce_error',
403 430 [
404 - '_fluentformnonce' => [
405 - __('Nonce verification failed, please try again.', 'fluentform'),
431 + [
432 + '_fluentformnonce' => [
433 + __('Nonce verification failed, please try again.', 'fluentform'),
434 + ],
406 435 ],
407 436 ],
408 437 FLUENTFORM_FRAMEWORK_UPGRADE,
409 438 'fluentForm/nonce_error',
@@ -409,9 +438,9 @@
409 438 'fluentForm/nonce_error',
410 439 'Use fluentForm/nonce_error instead of fluentForm_nonce_error.'
411 440 );
412 441
413 - $errors = $this->app->applyFilters('fluentForm/nonce_error', $errors);
442 + $errors = $this->app->applyFilters('fluentform/nonce_error', $errors);
414 443 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
415 444 throw new ValidationException('', 422, null, ['errors' => $errors]);
416 445 }
417 446 }
@@ -512,8 +541,12 @@
512 541 }
513 542
514 543 public function isCleanTalkSpam($formData, $form)
515 544 {
545 + if (CleanTalkHandler::isCleantalkActivated() || CleanTalkHandler::hasExecuted()) {
546 + return false;
547 + }
548 +
516 549 if (!CleanTalkHandler::isEnabled()) {
517 550 return false;
518 551 }
519 552 $isSpamCheck = apply_filters('fluentform/cleantalk_check_spam', true, $form->id, $formData);
@@ -701,63 +734,347 @@
701 734 return [$rules, $messages];
702 735 }
703 736
704 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 + /**
705 971 * Get IP info from ipinfo.io
706 972 *
707 - * @throws ValidationException
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
708 979 */
709 980 private function getIpInfo($ip) {
710 981 $token = Helper::getIpinfo();
711 -
712 - if (!$token) {
982 +
983 + if (!$token || get_transient('fluentform_geo_ipinfo_backoff')) {
713 984 return false;
714 985 }
715 -
716 - $url = 'https://ipinfo.io/' . $ip . '?token=' . $token;
717 - $data = wp_remote_get($url);
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 +
718 1000 $code = wp_remote_retrieve_response_code($data);
719 - $body = wp_remote_retrieve_body($data);
720 - $result = \json_decode($body, true);
721 - if ($code === 200) {
722 - return $result;
723 - } else {
724 - $message = __('Sorry! There is an error in your geocode IP address settings. Please check the token', 'fluentform');
725 - self::throwValidationException($message);
1001 +
1002 + if (200 !== $code) {
1003 + if (self::isProviderWideFailure($code)) {
1004 + self::backOffProvider('ipinfo');
1005 + }
1006 +
1007 + return false;
726 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;
727 1021 }
728 1022
729 1023 /**
730 - * Get IP and Country from geoplugin
1024 + * Get IP and Country from apip.cc, falling back to the request headers.
731 1025 *
732 - * @throws ValidationException
1026 + * @return string|null
733 1027 */
734 - private function getIpBasedOnCountry($ip) {
735 - $request = wp_remote_get("https://apip.cc/api-json/{$ip}");
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 +
736 1044 $code = wp_remote_retrieve_response_code($request);
737 1045
738 - $message = __('Sorry! There is an error occurred in getting Country using ip-api.com. Please check form settings and try again.', 'fluentform');
1046 + if (200 !== $code) {
1047 + if (self::isProviderWideFailure($code)) {
1048 + self::backOffProvider('apip');
1049 + }
739 1050
740 - if ($code === 200) {
741 - $body = wp_remote_retrieve_body($request);
742 - $body = \json_decode($body, true);
743 - $status = Arr::get($body, 'status', false) === 'success';
744 -
745 - if (!$status) {
746 - return Helper::getCountryCodeFromHeaders();
747 - }
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 + }
748 1056
749 - if ($country = Arr::get($body,'CountryCode')) {
750 - return $country;
751 - } else {
752 - self::throwValidationException($message);
753 - }
754 - } else {
755 - if ($country = Helper::getCountryCodeFromHeaders()) {
756 - return $country;
757 - }
758 - self::throwValidationException($message);
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;
759 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);
760 1077 }
761 1078
762 1079 /**
763 1080 * @param $value
@@ -764,23 +1081,93 @@
764 1081 * @param $providedKeywords
765 1082 * @return bool
766 1083 */
767 1084 public static function containsRestrictedKeywords($value, $providedKeywords) {
768 - preg_match_all('/\b[\p{L}\d\s]+\b/u', $value, $matches);
769 - $words = $matches[0] ?? [];
1085 + $value = (string) $value;
1086 + if ('' === $value) {
1087 + return false;
1088 + }
770 1089
771 - foreach ($providedKeywords as $keyword) {
772 - foreach ($words as $word) {
773 - if (
774 - strtoupper($word) === strtoupper($keyword) ||
775 - preg_match('/\b' . strtoupper($keyword) . '\b/', strtoupper($word))
776 - ) {
777 - return true;
778 - }
1090 + foreach ((array) $providedKeywords as $keyword) {
1091 + $keyword = (string) $keyword;
1092 + if ('' === $keyword || self::isUnusableKeyword($keyword)) {
1093 + continue;
779 1094 }
1095 +
1096 + if (preg_match(self::keywordPattern($keyword), $value)) {
1097 + return true;
1098 + }
780 1099 }
781 1100
782 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';
783 1170 }
784 1171
785 1172
786 1173 /**