PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
← All changes | app/Services/Form/FormValidationService.php +431 -50 6.2.56.2.14 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);
@@ -701,63 +728,347 @@
701 728 return [$rules, $messages];
702 729 }
703 730
704 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 + /**
705 965 * Get IP info from ipinfo.io
706 966 *
707 - * @throws ValidationException
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
708 973 */
709 974 private function getIpInfo($ip) {
710 975 $token = Helper::getIpinfo();
711 -
712 - if (!$token) {
976 +
977 + if (!$token || get_transient('fluentform_geo_ipinfo_backoff')) {
713 978 return false;
714 979 }
715 -
716 - $url = 'https://ipinfo.io/' . $ip . '?token=' . $token;
717 - $data = wp_remote_get($url);
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 +
718 994 $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);
995 +
996 + if (200 !== $code) {
997 + if (self::isProviderWideFailure($code)) {
998 + self::backOffProvider('ipinfo');
999 + }
1000 +
1001 + return false;
726 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;
727 1015 }
728 1016
729 1017 /**
730 - * Get IP and Country from geoplugin
1018 + * Get IP and Country from apip.cc, falling back to the request headers.
731 1019 *
732 - * @throws ValidationException
1020 + * @return string|null
733 1021 */
734 - private function getIpBasedOnCountry($ip) {
735 - $request = wp_remote_get("https://apip.cc/api-json/{$ip}");
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 +
736 1038 $code = wp_remote_retrieve_response_code($request);
737 1039
738 - $message = __('Sorry! There is an error occurred in getting Country using ip-api.com. Please check form settings and try again.', 'fluentform');
1040 + if (200 !== $code) {
1041 + if (self::isProviderWideFailure($code)) {
1042 + self::backOffProvider('apip');
1043 + }
739 1044
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 - }
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 + }
748 1050
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);
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;
759 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);
760 1071 }
761 1072
762 1073 /**
763 1074 * @param $value
@@ -764,23 +1075,93 @@
764 1075 * @param $providedKeywords
765 1076 * @return bool
766 1077 */
767 1078 public static function containsRestrictedKeywords($value, $providedKeywords) {
768 - preg_match_all('/\b[\p{L}\d\s]+\b/u', $value, $matches);
769 - $words = $matches[0] ?? [];
1079 + $value = (string) $value;
1080 + if ('' === $value) {
1081 + return false;
1082 + }
770 1083
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 - }
1084 + foreach ((array) $providedKeywords as $keyword) {
1085 + $keyword = (string) $keyword;
1086 + if ('' === $keyword || self::isUnusableKeyword($keyword)) {
1087 + continue;
779 1088 }
1089 +
1090 + if (preg_match(self::keywordPattern($keyword), $value)) {
1091 + return true;
1092 + }
780 1093 }
781 1094
782 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';
783 1164 }
784 1165
785 1166
786 1167 /**