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/Helpers/Helper.php +384 -90 6.2.56.2.14 View file →
@@ -44,19 +44,48 @@
44 44 $input = sanitize_text_field($input);
45 45 }
46 46 } elseif (is_array($input)) {
47 47 foreach ($input as $key => &$value) {
48 - $attribute = $attribute ? $attribute . '[' . $key . ']' : $key;
48 + // Local var: mutating $attribute here would collapse every sibling
49 + // after the first onto a bare key, resolving nested inputs to the wrong element.
50 + $childAttribute = $attribute ? $attribute . '[' . $key . ']' : $key;
49 51
50 - $value = static::sanitizer($value, $attribute, $fields);
51 -
52 - $attribute = null;
52 + $value = static::sanitizer($value, $childAttribute, $fields);
53 53 }
54 + unset($value);
54 55 }
55 56
56 57 return $input;
57 58 }
58 59
60 + /**
61 + * Flatten a request value into a plain, printable string.
62 + *
63 + * Request values are string or array. Walks nested arrays so a crafted
64 + * param[][] cannot raise an "Array to string conversion" notice, and the
65 + * caller can escape the result in one pass instead of branching on shape.
66 + *
67 + * @param mixed $value
68 + *
69 + * @return string
70 + */
71 + public static function flattenRequestValue($value)
72 + {
73 + if (!is_array($value)) {
74 + return is_scalar($value) ? (string) $value : '';
75 + }
76 +
77 + $flat = [];
78 +
79 + array_walk_recursive($value, function ($item) use (&$flat) {
80 + if (is_scalar($item)) {
81 + $flat[] = (string) $item;
82 + }
83 + });
84 +
85 + return implode(', ', $flat);
86 + }
87 +
59 88 public static function isOptionGroup($option)
60 89 {
61 90 return is_array($option)
62 91 && ArrayHelper::get($option, 'type') === 'group'
@@ -62,8 +91,78 @@
62 91 && ArrayHelper::get($option, 'type') === 'group'
63 92 && is_array(ArrayHelper::get($option, 'options'));
64 93 }
65 94
95 + /*
96 + * Int or nothing. Persisting free text here would hand the one user class
97 + * this sanitizer exists to contain an arbitrary string in form_fields, for a
98 + * value with no PHP consumer at all — it only keys the editor's Vue list.
99 + * A non-numeric id collapses to 0, and ensureUniqueIds() regenerates it on
100 + * load. Numerics must stay numeric for the same reason: as the string "0" it
101 + * would be JS-truthy, and ensureUniqueIds() only regenerates ids that are
102 + * falsy or already seen.
103 + */
104 + public static function sanitizeOptionId($value)
105 + {
106 + return is_numeric($value) ? (int) $value : 0;
107 + }
108 +
109 + /*
110 + * sanitize_title, not sanitize_key: Pro writes inventory slugs with
111 + * sanitize_title (InventoryController), which percent-encodes non-ASCII.
112 + * sanitize_key strips '%', producing a slug that no longer exists — and a
113 + * truthy-but-missing slug resolves to quantity 0 in InventoryValidation, so
114 + * the option fails closed as permanently stocked out. sanitize_title is
115 + * idempotent over its own output and still neutralises markup.
116 + *
117 + * The is_scalar guard is ours: unlike sanitize_key, sanitize_title has no
118 + * guard of its own and raises a TypeError on PHP 8 for an array value.
119 + */
120 + public static function sanitizeOptionSlug($value)
121 + {
122 + return is_scalar($value) ? sanitize_title($value) : '';
123 + }
124 +
125 + /**
126 + * A key is unsafe when it opens a handler, or when it carries a character that
127 + * ENDS an attribute name in the HTML tokeniser -- whitespace, quote, slash,
128 + * equals or angle bracket. `esc_attr()` leaves those intact, so `x onclick`
129 + * renders as two attributes and the second one is live.
130 + *
131 + * Deny those characters rather than allow-list a charset: an allow-list also
132 + * rejects the legal-but-unusual keys real sites carry (leading underscore,
133 + * non-Latin names, framework prefixes) and silently drops working markup.
134 + *
135 + * @param string|int $key
136 + * @return bool
137 + */
138 + public static function isSafeAttributeKey($key)
139 + {
140 + $key = (string) $key;
141 +
142 + if ('' === $key || preg_match('/^on[a-z]/i', $key)) {
143 + return false;
144 + }
145 +
146 + return !preg_match('/[\s"\'\/=<>`]|[\x00-\x1F\x7F]/', $key);
147 + }
148 +
149 + /*
150 + * Keys the whitelist above drops but the editor and Pro Inventory need back.
151 + * They are sanitized rather than passed through, since preserving unknown
152 + * keys verbatim would defeat the whitelist for exactly the users this path
153 + * protects. Absent keys stay absent: an invented `quantity => 0` reads as
154 + * "stock out" to InventoryValidation.
155 + */
156 + protected static function optionPassthroughMap()
157 + {
158 + return [
159 + 'id' => [self::class, 'sanitizeOptionId'],
160 + 'quantity' => 'intval',
161 + 'global_inventory' => [self::class, 'sanitizeOptionSlug'],
162 + ];
163 + }
164 +
66 165 public static function sanitizeAdvancedOptions($options, $depth = 0)
67 166 {
68 167 if (!is_array($options)) {
69 168 return [];
@@ -86,23 +185,56 @@
86 185 $sanitized = array_merge($sanitized, $groupOptions);
87 186 continue;
88 187 }
89 188
90 - $sanitized[] = [
189 + $groupLabel = ArrayHelper::get($option, 'label', '');
190 +
191 + $group = [
91 192 'type' => 'group',
92 - 'label' => wp_kses_post(ArrayHelper::get($option, 'label', '')),
193 + 'label' => wp_kses_post(is_scalar($groupLabel) ? $groupLabel : ''),
93 194 'options' => $groupOptions,
94 195 ];
196 +
197 + // Groups are keyed on group.id in the editor exactly like leaf
198 + // options, and is_open is user-visible collapse state.
199 + if (array_key_exists('id', $option)) {
200 + $group['id'] = self::sanitizeOptionId($option['id']);
201 + }
202 + if (array_key_exists('is_open', $option)) {
203 + $group['is_open'] = (bool) $option['is_open'];
204 + }
205 +
206 + $sanitized[] = $group;
95 207 continue;
96 208 }
97 209
98 - $sanitized[] = [
99 - 'label' => wp_kses_post(ArrayHelper::get($option, 'label', '')),
100 - 'value' => sanitize_text_field(ArrayHelper::get($option, 'value', '')),
101 - 'image' => sanitize_url(ArrayHelper::get($option, 'image', '')),
102 - 'calc_value' => sanitize_text_field(ArrayHelper::get($option, 'calc_value', '')),
210 + /*
211 + * Non-scalars are flattened to '' before the WP sanitisers see them:
212 + * wp_kses_post() and sanitize_url() have no guard of their own and
213 + * raise a TypeError on PHP 8 for an array, which would be an uncaught
214 + * 500 on save for exactly the users this whitelist protects.
215 + */
216 + $scalar = function ($key) use ($option) {
217 + $value = ArrayHelper::get($option, $key, '');
218 +
219 + return is_scalar($value) ? $value : '';
220 + };
221 +
222 + $clean = [
223 + 'label' => wp_kses_post($scalar('label')),
224 + 'value' => sanitize_text_field($scalar('value')),
225 + 'image' => sanitize_url($scalar('image')),
226 + 'calc_value' => sanitize_text_field($scalar('calc_value')),
103 227 'disabled' => ArrayHelper::isTrue($option, 'disabled'),
104 228 ];
229 +
230 + foreach (self::optionPassthroughMap() as $key => $sanitizer) {
231 + if (array_key_exists($key, $option)) {
232 + $clean[$key] = call_user_func($sanitizer, $option[$key]);
233 + }
234 + }
235 +
236 + $sanitized[] = $clean;
105 237 }
106 238
107 239 return $sanitized;
108 240 }
@@ -205,9 +337,9 @@
205 337 $statuses = apply_filters_deprecated(
206 338 'fluentform_entry_statuses_core',
207 339 [
208 340 $statuses,
209 - $form_id
341 + $form_id,
210 342 ],
211 343 FLUENTFORM_FRAMEWORK_UPGRADE,
212 344 'fluentform/entry_statuses_core',
213 345 'Use fluentform/entry_statuses_core instead of fluentform_entry_statuses_core.'
@@ -214,13 +346,21 @@
214 346 );
215 347
216 348 $statuses = apply_filters('fluentform/entry_statuses_core', $statuses, $form_id);
217 349
218 - $statuses['trashed'] = 'Trashed';
350 + $statuses['spam'] = __('Spam', 'fluentform');
219 351
352 + $statuses['trashed'] = __('Trashed', 'fluentform');
353 +
220 354 return $statuses;
221 355 }
222 356
357 + // Statuses a caller may write by hand; add-ons withhold the ones they own as workflow steps.
358 + public static function getMutableEntryStatuses($form_id = false, $submission_id = null)
359 + {
360 + return apply_filters('fluentform/entry_statuses_for_mutation', static::getEntryStatuses($form_id), $form_id, $submission_id);
361 + }
362 +
223 363 public static function getReportableInputs()
224 364 {
225 365 $data = [
226 366 'select',
@@ -234,9 +374,9 @@
234 374
235 375 $data = apply_filters_deprecated(
236 376 'fluentform_reportable_inputs',
237 377 [
238 - $data
378 + $data,
239 379 ],
240 380 FLUENTFORM_FRAMEWORK_UPGRADE,
241 381 'fluentform/reportable_inputs',
242 382 'Use fluentform/reportable_inputs instead of fluentform_reportable_inputs.'
@@ -249,9 +389,9 @@
249 389 {
250 390 $grid = apply_filters_deprecated(
251 391 'fluentform_subfield_reportable_inputs',
252 392 [
253 - ['tabular_grid']
393 + ['tabular_grid'],
254 394 ],
255 395 FLUENTFORM_FRAMEWORK_UPGRADE,
256 396 'fluentform/subfield_reportable_inputs',
257 397 'Use fluentform/subfield_reportable_inputs instead of fluentform_subfield_reportable_inputs.'
@@ -262,9 +402,9 @@
262 402
263 403 public static function getFormMeta($formId, $metaKey, $default = '', $forced = false)
264 404 {
265 405 $formattedValues = self::$formMetaCache[$formId] ?? [];
266 -
406 +
267 407 if (!isset(self::$formMetaCache[$formId]) || $forced) {
268 408 $formMetas = FormMeta::where('form_id', $formId)
269 409 ->get();
270 410
@@ -270,9 +410,9 @@
270 410
271 411 $formattedValues = [];
272 412 foreach ($formMetas as $formMeta) {
273 413 $value = $formMeta->value;
274 -
414 +
275 415 $decoded = json_decode($value ?? '', true);
276 416 if (is_array($decoded)) {
277 417 $value = $decoded;
278 418 }
@@ -390,9 +530,9 @@
390 530 }
391 531
392 532 public static function getFormInstaceClass($formId)
393 533 {
394 - static::$formInstance += 1;
534 + static::$formInstance++;
395 535
396 536 return 'ff_form_instance_' . $formId . '_' . static::$formInstance;
397 537 }
398 538
@@ -410,9 +550,9 @@
410 550 'fluent_forms_settings',
411 551 'fluent_forms_add_ons',
412 552 'fluent_forms_docs',
413 553 'fluent_forms_payment_entries',
414 - 'fluent_forms_reports'
554 + 'fluent_forms_reports',
415 555 ];
416 556
417 557 $status = true;
418 558
@@ -424,9 +564,9 @@
424 564
425 565 $status = apply_filters_deprecated(
426 566 'fluentform_is_admin_page',
427 567 [
428 - $status
568 + $status,
429 569 ],
430 570 FLUENTFORM_FRAMEWORK_UPGRADE,
431 571 'fluentform/is_admin_page',
432 572 'Use fluentform/is_admin_page instead of fluentform_is_admin_page.'
@@ -456,9 +596,9 @@
456 596
457 597 $result = shortcode_parse_atts($parsedCode);
458 598
459 599 if (!empty($result[$selector])) {
460 - if ($tag == 'fluentform' && !empty($result['type']) && $result['type'] == 'conversational') {
600 + if ('fluentform' == $tag && !empty($result['type']) && 'conversational' == $result['type']) {
461 601 continue;
462 602 }
463 603
464 604 $ids[$result[$selector]] = $result[$selector];
@@ -467,9 +607,9 @@
467 607
468 608 if ($theme) {
469 609 $attributes[] = [
470 610 'formId' => $result[$selector],
471 - 'theme' => $theme
611 + 'theme' => $theme,
472 612 ];
473 613 }
474 614 }
475 615 }
@@ -490,9 +630,9 @@
490 630 if (!function_exists('parse_blocks')) {
491 631 return $ids;
492 632 }
493 633
494 - $has_block = false !== strpos($content, '<!-- wp:fluentfom/guten-block' . ' ');
634 + $has_block = false !== strpos($content, '<!-- wp:fluentfom/guten-block ');
495 635
496 636 if (!$has_block) {
497 637 return $ids;
498 638 }
@@ -504,9 +644,9 @@
504 644 }
505 645
506 646 $hasBlock = strpos($block['blockName'], 'fluentfom/guten-block') === 0;
507 647 if ($hasBlock) {
508 - $formId = (int)$block['attrs']['formId'];
648 + $formId = (int) $block['attrs']['formId'];
509 649
510 650 $ids[] = $formId;
511 651
512 652 $theme = ArrayHelper::get($block, 'attrs.themeStyle');
@@ -513,9 +653,9 @@
513 653
514 654 if ($theme) {
515 655 $attributes[] = [
516 656 'formId' => $formId,
517 - 'theme' => $theme
657 + 'theme' => $theme,
518 658 ];
519 659 }
520 660 }
521 661 }
@@ -549,10 +689,10 @@
549 689 if (!$form) {
550 690 return false;
551 691 }
552 692
553 - $fieldsJson = (string)($form->form_fields ?? '');
554 - if ($fieldsJson === '') {
693 + $fieldsJson = (string) ($form->form_fields ?? '');
694 + if ('' === $fieldsJson) {
555 695 return false;
556 696 }
557 697
558 698 $fields = json_decode($fieldsJson, true);
@@ -559,9 +699,9 @@
559 699 if (!is_array($fields)) {
560 700 return false;
561 701 }
562 702
563 - return (bool)ArrayHelper::get($fields, 'stepsWrapper');
703 + return (bool) ArrayHelper::get($fields, 'stepsWrapper');
564 704 }
565 705
566 706 public static function hasFormElement($formId, $elementName)
567 707 {
@@ -583,10 +723,10 @@
583 723
584 724 // if form has pending payment then the value doesn't exist in EntryDetails table
585 725 // further checking on Submission table if the value exists
586 726 if (!$exist && $form->has_payment) {
587 - $escapedKey = json_encode($fieldName);
588 - $escapedValue = json_encode($inputValue);
727 + $escapedKey = wp_json_encode($fieldName);
728 + $escapedValue = wp_json_encode($inputValue);
589 729 $searchPattern = trim($escapedKey, '"') . '":' . $escapedValue;
590 730 $searchPattern = addcslashes($searchPattern, '%_');
591 731
592 732 $exist = Submission::where('form_id', $form->id)
@@ -671,9 +811,9 @@
671 811
672 812 $data = apply_filters_deprecated(
673 813 'fluentform_numeric_styles',
674 814 [
675 - $data
815 + $data,
676 816 ],
677 817 FLUENTFORM_FRAMEWORK_UPGRADE,
678 818 'fluentform/numeric_styles',
679 819 'Use fluentform/numeric_styles instead of fluentform_numeric_styles.'
@@ -752,12 +892,10 @@
752 892 foreach ($columns as $column) {
753 893 $columnInputs = static::getFieldNamesStatuses(ArrayHelper::get($column, 'fields', []));
754 894 $names = array_merge($names, $columnInputs);
755 895 }
756 - } else {
757 - if ($name = ArrayHelper::get($field, 'attributes.name')) {
896 + } elseif ($name = ArrayHelper::get($field, 'attributes.name')) {
758 897 $names[] = $name;
759 - }
760 898 }
761 899 }
762 900
763 901 return $names;
@@ -806,16 +944,20 @@
806 944 $optionValues = array_values(array_filter(array_map(
807 945 'sanitize_text_field',
808 946 array_column(static::flattenAdvancedOptions($formattedOptions), 'value')
809 947 ), function ($value) {
810 - return $value !== '';
948 + return '' !== $value;
811 949 }));
812 950
813 951 if (count($optionValues) !== count(array_unique($optionValues))) {
814 - $duplicates[] = ArrayHelper::get($field, 'settings.admin_field_label')
815 - ?: ArrayHelper::get($field, 'settings.label')
816 - ?: ArrayHelper::get($field, 'attributes.name')
817 - ?: __('Ranking Field', 'fluentform');
952 + $fieldLabel = ArrayHelper::get($field, 'settings.admin_field_label');
953 + if (!$fieldLabel) {
954 + $fieldLabel = ArrayHelper::get($field, 'settings.label');
955 + }
956 + if (!$fieldLabel) {
957 + $fieldLabel = ArrayHelper::get($field, 'attributes.name');
958 + }
959 + $duplicates[] = $fieldLabel ? $fieldLabel : __('Ranking Field', 'fluentform');
818 960 }
819 961 }
820 962
821 963 return $duplicates;
@@ -838,12 +980,10 @@
838 980 if ('conversational' == $type) {
839 981 return static::getConversionUrl($formId);
840 982 } elseif ('classic' == $type) {
841 983 return site_url('?fluent_forms_pages=1&design_mode=1&preview_id=' . $formId) . '#ff_preview';
842 - } else {
843 - if (static::isConversionForm($formId)) {
984 + } elseif (static::isConversionForm($formId)) {
844 985 return static::getConversionUrl($formId);
845 - }
846 986 }
847 987
848 988 return site_url('?fluent_forms_pages=1&design_mode=1&preview_id=' . $formId) . '#ff_preview';
849 989 }
@@ -869,9 +1009,9 @@
869 1009
870 1010 $slug = apply_filters_deprecated(
871 1011 'fluentform_conversational_url_slug',
872 1012 [
873 - 'fluent-form'
1013 + 'fluent-form',
874 1014 ],
875 1015 FLUENTFORM_FRAMEWORK_UPGRADE,
876 1016 'fluentform/conversational_url_slug',
877 1017 'Use fluentform/conversational_url_slug instead of fluentform_conversational_url_slug.'
@@ -903,9 +1043,9 @@
903 1043
904 1044 $locations = apply_filters_deprecated(
905 1045 'fluentform_file_upload_options',
906 1046 [
907 - $locations
1047 + $locations,
908 1048 ],
909 1049 FLUENTFORM_FRAMEWORK_UPGRADE,
910 1050 'fluentform/file_upload_options',
911 1051 'Use fluentform/file_upload_options instead of fluentform_file_upload_options'
@@ -957,9 +1097,9 @@
957 1097 }
958 1098
959 1099 public static function sanitizeForCSV($content)
960 1100 {
961 - $formulas = ['=', '-', '+', '@', "\t", "\r"];
1101 + $formulas = ['=', '-', '+', '@', "\t", "\r", "\n"];
962 1102
963 1103 $formulas = apply_filters('fluentform/csv_sanitize_formulas', $formulas);
964 1104
965 1105 if (Str::startsWith($content, $formulas)) {
@@ -988,9 +1128,9 @@
988 1128 $isTruncate = apply_filters_deprecated(
989 1129 'fluentform_truncate_password_values',
990 1130 [
991 1131 true,
992 - $formId
1132 + $formId,
993 1133 ],
994 1134 FLUENTFORM_FRAMEWORK_UPGRADE,
995 1135 'fluentform/truncate_password_values',
996 1136 'Use fluentform/truncate_password_values instead of fluentform_truncate_password_values.'
@@ -1010,10 +1150,9 @@
1010 1150 $field,
1011 1151 $rowJoiner = '<br />',
1012 1152 $colJoiner = ', ',
1013 1153 $type = ''
1014 - )
1015 - {
1154 + ) {
1016 1155 if (!$girdData || !$field) {
1017 1156 return '';
1018 1157 }
1019 1158 $girdRows = ArrayHelper::get($field, 'raw.settings.grid_rows', []);
@@ -1038,9 +1177,9 @@
1038 1177 $_colJoiner = $colJoiner;
1039 1178 if ($girdCols && isset($girdCols[$item])) {
1040 1179 $item = $girdCols[$item];
1041 1180 }
1042 - if ($index == (count($column) - 1)) {
1181 + if ((count($column) - 1) == $index) {
1043 1182 $_colJoiner = '';
1044 1183 }
1045 1184 $value .= $item . $_colJoiner;
1046 1185 }
@@ -1115,9 +1254,9 @@
1115 1254
1116 1255 public static function isAutosaveEnabled()
1117 1256 {
1118 1257 $autosaveEnabled = ArrayHelper::get(get_option('_fluentform_global_form_settings'), 'misc.autosave_enabled', 'no');
1119 - return $autosaveEnabled === 'yes';
1258 + return 'yes' === $autosaveEnabled;
1120 1259 }
1121 1260
1122 1261 public static function maybeDecryptUrl($url)
1123 1262 {
@@ -1143,9 +1282,9 @@
1143 1282
1144 1283 public static function isBlockEditor()
1145 1284 {
1146 1285 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking REST API context
1147 - return defined('REST_REQUEST') && REST_REQUEST && !empty($_REQUEST['context']) && $_REQUEST['context'] === 'edit';
1286 + return defined('REST_REQUEST') && REST_REQUEST && !empty($_REQUEST['context']) && 'edit' === $_REQUEST['context'];
1148 1287 }
1149 1288
1150 1289 public static function resolveValidationRulesGlobalOption(&$field)
1151 1290 {
@@ -1152,16 +1291,14 @@
1152 1291 if (isset($field['fields']) && is_array($field['fields'])) {
1153 1292 foreach ($field['fields'] as &$subField) {
1154 1293 static::resolveValidationRulesGlobalOption($subField);
1155 1294 }
1156 - } else {
1157 - if (ArrayHelper::get($field, 'settings.validation_rules')) {
1158 - foreach ($field['settings']['validation_rules'] as $key => &$rule) {
1159 - if (!isset($rule['global'])) {
1160 - $rule['global'] = false;
1161 - }
1162 - $rule['global_message'] = static::getGlobalDefaultMessage($key);
1295 + } elseif (ArrayHelper::get($field, 'settings.validation_rules')) {
1296 + foreach ($field['settings']['validation_rules'] as $key => &$rule) {
1297 + if (!isset($rule['global'])) {
1298 + $rule['global'] = false;
1163 1299 }
1300 + $rule['global_message'] = static::getGlobalDefaultMessage($key);
1164 1301 }
1165 1302 }
1166 1303 }
1167 1304
@@ -1196,9 +1333,10 @@
1196 1333 }
1197 1334 $fieldType = ArrayHelper::get($rawField, 'element');
1198 1335 $rawField = apply_filters('fluentform/rendering_field_data_' . $fieldType, $rawField, $form);
1199 1336 $options = [];
1200 - if ("net_promoter_score" === $fieldType) {
1337 + $otherPrefix = '';
1338 + if ('net_promoter_score' === $fieldType) {
1201 1339 $options = array_flip(ArrayHelper::get($rawField, 'options', []));
1202 1340 } elseif ('ratings' == $fieldType) {
1203 1341 $options = array_keys(ArrayHelper::get($rawField, 'options', []));
1204 1342 } elseif ('gdpr_agreement' == $fieldType) {
@@ -1221,16 +1359,17 @@
1221 1359 // @todo : Update all reference in form templates
1222 1360 }
1223 1361
1224 1362 $options = array_column(self::flattenAdvancedOptions($formattedOptions), 'value');
1225 -
1363 +
1226 1364 // Add field-specific __ff_other__ to options if "Other" option is enabled
1227 1365 if (in_array($fieldType, ['input_checkbox', 'input_radio']) &&
1228 1366 ArrayHelper::get($rawField, 'settings.enable_other_option') === 'yes') {
1229 1367 $fieldName = sanitize_key(str_replace(['[', ']'], '', ArrayHelper::get($rawField, 'attributes.name', '')));
1230 1368 $options[] = '__ff_other_' . $fieldName . '__';
1369 + $otherPrefix = static::getOtherOptionValuePrefix($rawField);
1231 1370 }
1232 - } elseif ("dynamic_field" == $fieldType) {
1371 + } elseif ('dynamic_field' == $fieldType) {
1233 1372 $dynamicFetchValue = 'yes' == ArrayHelper::get($rawField, 'settings.dynamic_fetch');
1234 1373 if ($dynamicFetchValue) {
1235 1374 $rawField = apply_filters('fluentform/dynamic_field_re_fetch_result_and_resolve_value', $rawField);
1236 1375 }
@@ -1261,13 +1400,13 @@
1261 1400 break;
1262 1401 }
1263 1402
1264 1403 $filteredValues = array_values(array_filter(array_map('sanitize_text_field', $inputValue), function ($value) {
1265 - return $value !== '';
1404 + return '' !== $value;
1266 1405 }));
1267 1406
1268 1407 $normalizedOptions = array_values(array_filter(array_map('sanitize_text_field', $options), function ($value) {
1269 - return $value !== '';
1408 + return '' !== $value;
1270 1409 }));
1271 1410
1272 1411 sort($filteredValues);
1273 1412 sort($normalizedOptions);
@@ -1284,30 +1423,28 @@
1284 1423 case 'terms_and_condition':
1285 1424 case 'input_checkbox':
1286 1425 case 'multi_select':
1287 1426 case 'dynamic_field_options':
1288 -
1289 1427 $skipValidationInputsWithOptions = apply_filters('fluentform/skip_validation_inputs_with_options', false, $fieldType, $form, $formData);
1290 1428 if ($skipValidationInputsWithOptions) {
1291 1429 break;
1292 1430 }
1293 1431 if (is_array($inputValue)) {
1294 - // Handle field-specific "Other" options for checkboxes
1295 - $filteredValues = array_filter($inputValue, function($value) {
1296 - // Skip field-specific other values and processed other values
1432 + // Skip "Other" values — raw, localized or legacy English prefix
1433 + $filteredValues = array_filter($inputValue, function ($value) use ($otherPrefix) {
1297 1434 return !preg_match('/^__ff_other_.*__$/', $value) &&
1298 - !preg_match('/^Other:\s/', $value);
1435 + !preg_match('/^Other:\s/', $value) &&
1436 + !($otherPrefix && 0 === strpos($value, $otherPrefix));
1299 1437 });
1300 1438 $isValid = array_diff($filteredValues, $options);
1301 1439 $isValid = empty($isValid);
1440 + } elseif (preg_match('/^__ff_other_.*__$/', $inputValue) ||
1441 + preg_match('/^Other:\s/', $inputValue) ||
1442 + ($otherPrefix && 0 === strpos($inputValue, $otherPrefix))) {
1443 + // Accept "Other" values — raw, localized or legacy English prefix
1444 + $isValid = true;
1302 1445 } else {
1303 - // Handle field-specific "Other" option for single values
1304 - if (preg_match('/^__ff_other_.*__$/', $inputValue) ||
1305 - preg_match('/^Other:\s/', $inputValue)) {
1306 - $isValid = true;
1307 - } else {
1308 - $isValid = in_array($inputValue, $options);
1309 - }
1446 + $isValid = in_array($inputValue, $options);
1310 1447 }
1311 1448 break;
1312 1449 case 'input_number':
1313 1450 if (is_array($inputValue)) {
@@ -1322,9 +1459,9 @@
1322 1459 case 'select_country':
1323 1460 $fieldData = ArrayHelper::get($field, 'raw');
1324 1461 $data = (new SelectCountry())->loadCountries($fieldData);
1325 1462 $validCountries = ArrayHelper::get($fieldData, 'settings.country_list.priority_based', []);
1326 - $validCountries = array_merge($validCountries, array_keys(ArrayHelper::get($data, 'options')));
1463 + $validCountries = array_merge($validCountries, array_keys((array) ArrayHelper::get($data, 'options', [])));
1327 1464 $isValid = in_array($inputValue, $validCountries);
1328 1465 break;
1329 1466 case 'repeater_field':
1330 1467 case 'repeater_container':
@@ -1369,8 +1506,137 @@
1369 1506 }
1370 1507 return $error;
1371 1508 }
1372 1509
1510 + /**
1511 + * Enforce how many options a field allows the user to pick.
1512 + *
1513 + * A field with no selections is left alone, so a floor never turns an
1514 + * optional field into a required one — that is what `required` is for.
1515 + *
1516 + * @param array $rawField
1517 + * @param mixed $inputValue
1518 + * @return array rule name => message, empty when within the limits
1519 + */
1520 + public static function validateSelectionLimits($rawField, $inputValue)
1521 + {
1522 + // Distinct choices, not array entries. The same option repeated is one
1523 + // answer: counting entries lets a crafted post satisfy a floor of two by
1524 + // sending one option twice, and lets padding manufacture a ceiling breach
1525 + // the visitor never made. No UI can produce a duplicate, so this only
1526 + // ever arrives crafted. A filled-in "Other" is a single element and still
1527 + // counts once.
1528 + $selected = is_array($inputValue) ? count(array_unique(array_filter($inputValue, function ($value) {
1529 + return '' !== $value && null !== $value;
1530 + }))) : (('' === $inputValue || null === $inputValue) ? 0 : 1);
1531 +
1532 + if (!$selected) {
1533 + return [];
1534 + }
1535 +
1536 + $rules = ArrayHelper::get($rawField, 'settings.validation_rules', []);
1537 +
1538 + $limits = [
1539 + 'min_selection' => ArrayHelper::get($rules, 'min_selection.value'),
1540 + 'max_selection' => static::resolveMaxSelection($rawField),
1541 + ];
1542 +
1543 + $errors = [];
1544 +
1545 + foreach ($limits as $rule => $limit) {
1546 + // '' is how "no limit" ships, so it must never mean a limit of zero.
1547 + if ('' === $limit || null === $limit || !is_numeric($limit)) {
1548 + continue;
1549 + }
1550 +
1551 + $limit = (int) $limit;
1552 +
1553 + if ($limit < 1) {
1554 + continue;
1555 + }
1556 +
1557 + $breached = 'min_selection' === $rule ? $selected < $limit : $selected > $limit;
1558 +
1559 + if ($breached) {
1560 + $errors[$rule] = static::getSelectionLimitMessage($rules, $rule);
1561 + }
1562 + }
1563 +
1564 + return $errors;
1565 + }
1566 +
1567 + /**
1568 + * The effective ceiling for a field, preferring the rule over the legacy
1569 + * `settings.max_selection`.
1570 + *
1571 + * The rule takes ownership as soon as its KEY exists, empty value included.
1572 + * Falling back on an empty value instead would make the limit unremovable:
1573 + * nothing writes to the legacy setting any more, so clearing the box in the
1574 + * editor would silently drop back to whatever was frozen there.
1575 + *
1576 + * @param array $field
1577 + * @return mixed
1578 + */
1579 + public static function resolveMaxSelection($field)
1580 + {
1581 + $rules = ArrayHelper::get($field, 'settings.validation_rules', []);
1582 +
1583 + if (is_array($rules) && array_key_exists('max_selection', $rules)) {
1584 + return ArrayHelper::get($rules, 'max_selection.value');
1585 + }
1586 +
1587 + return ArrayHelper::get($field, 'settings.max_selection');
1588 + }
1589 +
1590 + /**
1591 + * The field's own wording for a breached limit, or the site-wide default.
1592 + *
1593 + * @param array $rules
1594 + * @param string $rule
1595 + * @return string
1596 + */
1597 + public static function getSelectionLimitMessage($rules, $rule)
1598 + {
1599 + // `global_message` is a copy taken when the field was last saved, so it
1600 + // goes stale the moment Global Settings change — resolve the live value
1601 + // the way every other rule does.
1602 + $message = ArrayHelper::isTrue($rules, $rule . '.global')
1603 + ? static::getGlobalDefaultMessage($rule)
1604 + : ArrayHelper::get($rules, $rule . '.message');
1605 +
1606 + if (!$message) {
1607 + $message = static::getGlobalDefaultMessage($rule);
1608 + }
1609 +
1610 + return apply_filters('fluentform/selection_limit_message', $message, $rule, $rules);
1611 + }
1612 +
1613 + /**
1614 + * Prefix used to store a checkable field's "Other" option value,
1615 + * built from the field's own (translated) label. Pass $form to run
1616 + * the field through the rendering filter (translation plugins) first.
1617 + *
1618 + * @param array $rawField
1619 + * @param object|null $form
1620 + * @return string
1621 + */
1622 + public static function getOtherOptionValuePrefix($rawField, $form = null)
1623 + {
1624 + $fieldType = ArrayHelper::get($rawField, 'element');
1625 + if ($form && $fieldType) {
1626 + $rawField = apply_filters('fluentform/rendering_field_data_' . $fieldType, $rawField, $form);
1627 + }
1628 +
1629 + $label = trim((string) ArrayHelper::get($rawField, 'settings.other_option_label'));
1630 +
1631 + if ('' === $label) {
1632 + $label = __('Other', 'fluentform');
1633 + }
1634 +
1635 + // Avoid "::" when the label already ends with a colon
1636 + return ':' === substr($label, -1) ? $label . ' ' : $label . ': ';
1637 + }
1638 +
1373 1639 public static function getWhiteListedFields($formId)
1374 1640 {
1375 1641 $whiteListedFields = [
1376 1642 '__fluent_form_embded_post_id',
@@ -1384,9 +1650,9 @@
1384 1650 '__entry_intermediate_hash',
1385 1651 '__square_payment_method_id',
1386 1652 '__square_verify_buyer_id',
1387 1653 'ct_bot_detector_event_token',
1388 - 'ff_ct_form_load_time'
1654 + 'ff_ct_form_load_time',
1389 1655 ];
1390 1656
1391 1657 return apply_filters('fluentform/white_listed_fields', $whiteListedFields, $formId);
1392 1658 }
@@ -1392,8 +1658,9 @@
1392 1658 }
1393 1659
1394 1660 /**
1395 1661 * Shortcode parse on validation message
1662 + *
1396 1663 * @param string $message
1397 1664 * @param object $form
1398 1665 * @param string $fieldName
1399 1666 * @return string
@@ -1400,9 +1667,9 @@
1400 1667 */
1401 1668 public static function shortCodeParseOnValidationMessage($message, $form, $fieldName)
1402 1669 {
1403 1670 // Return early if form is null to prevent errors
1404 - if ($form === null) {
1671 + if (null === $form) {
1405 1672 return $message;
1406 1673 }
1407 1674
1408 1675 // For validation message there is no entry & form data
@@ -1408,9 +1675,9 @@
1408 1675 // For validation message there is no entry & form data
1409 1676 // Add 'current_field' name as data array to resolve {labels.current_field} shortcode if it has
1410 1677 return ShortCodeParser::parse(
1411 1678 $message,
1412 - (object)['response' => "", 'form_id' => $form->id],
1679 + (object) ['response' => '', 'form_id' => $form->id],
1413 1680 ['current_field' => $fieldName],
1414 1681 $form
1415 1682 );
1416 1683 }
@@ -1508,8 +1775,25 @@
1508 1775 {
1509 1776 return defined('FLUENTFORMPRO');
1510 1777 }
1511 1778
1779 + public static function utmUrl($baseUrl, $utmContent = '', $utmCampaign = 'upgrade_pro')
1780 + {
1781 + $params = [
1782 + 'utm_source' => 'fluent-forms',
1783 + 'utm_medium' => self::hasPro() ? 'pro_plugin' : 'free_plugin',
1784 + 'utm_campaign' => $utmCampaign,
1785 + 'utm_term' => FLUENTFORM_VERSION,
1786 + 'theme_style' => fluentform_get_active_theme_slug(),
1787 + ];
1788 +
1789 + if ($utmContent) {
1790 + $params['utm_content'] = $utmContent;
1791 + }
1792 +
1793 + return add_query_arg($params, $baseUrl);
1794 + }
1795 +
1512 1796 public static function getLandingPageEnabledForms()
1513 1797 {
1514 1798 if (class_exists(\FluentFormPro\classes\SharePage\SharePage::class)) {
1515 1799 if (method_exists(\FluentFormPro\classes\SharePage\SharePage::class, 'getLandingPageFormIds')) {
@@ -1537,10 +1821,17 @@
1537 1821 {
1538 1822 return home_url($args);
1539 1823 }
1540 1824
1541 - public static function getCountryCodeFromHeaders()
1825 + public static function getCountryCodeFromHeaders($forRestriction = false)
1542 1826 {
1827 + // SECURITY (FINDING-26): CDN country headers are client-spoofable. Trust them for analytics
1828 + // storage (spoof is cosmetic) but not for restriction enforcement (spoof = bypass). Filterable.
1829 + $trustHeaders = apply_filters('fluentform/trust_geo_headers', !$forRestriction);
1830 + if (!$trustHeaders) {
1831 + return null;
1832 + }
1833 +
1543 1834 $headers = [
1544 1835 // Cloudflare (most common)
1545 1836 'HTTP_CF_IPCOUNTRY',
1546 1837 'CF-IPCountry',
@@ -1564,9 +1855,9 @@
1564 1855 // General purpose country headers
1565 1856 'HTTP_X_COUNTRY',
1566 1857 'X-Country',
1567 1858 'HTTP_X_COUNTRY_ISO',
1568 - 'X-Country-ISO'
1859 + 'X-Country-ISO',
1569 1860 ];
1570 1861
1571 1862 foreach ($headers as $header) {
1572 1863 // Try directly from $_SERVER
@@ -1572,10 +1863,10 @@
1572 1863 // Try directly from $_SERVER
1573 1864 if (isset($_SERVER[$header])) {
1574 1865 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Country code from CDN/proxy header, validated below
1575 1866 $code = trim(sanitize_text_field(wp_unslash($_SERVER[$header])));
1576 - } // Try with HTTP_ prefix if not already present
1577 - elseif (strpos($header, 'HTTP_') !== 0) {
1867 + } elseif (strpos($header, 'HTTP_') !== 0) {
1868 + // Try with HTTP_ prefix if not already present
1578 1869 $httpHeader = 'HTTP_' . str_replace('-', '_', strtoupper($header));
1579 1870 if (isset($_SERVER[$httpHeader])) {
1580 1871 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Country code from CDN/proxy header, validated below
1581 1872 $code = trim(sanitize_text_field(wp_unslash($_SERVER[$httpHeader])));
@@ -1586,9 +1877,9 @@
1586 1877 continue;
1587 1878 }
1588 1879
1589 1880 // Basic validation - should be 2-letter country code
1590 - if (!empty($code) && is_string($code) && strlen($code) === 2 && ctype_alpha($code) && $code !== 'XX') {
1881 + if (!empty($code) && is_string($code) && 2 === strlen($code) && ctype_alpha($code) && 'XX' !== $code) {
1591 1882 return strtoupper($code);
1592 1883 }
1593 1884 }
1594 1885
@@ -1596,8 +1887,9 @@
1596 1887 }
1597 1888
1598 1889 /**
1599 1890 * Fixes PHP Object Injection Vulnerability
1891 + *
1600 1892 * @param $data
1601 1893 * @return mixed
1602 1894 */
1603 1895 public static function safeUnserialize($data)
@@ -1607,12 +1899,13 @@
1607 1899 }
1608 1900 return $data;
1609 1901 }
1610 1902
1611 - /**
1612 - * If elementor editor is open
1613 - * @return bool
1614 - */
1903 + /**
1904 + * If elementor editor is open
1905 + *
1906 + * @return bool
1907 + */
1615 1908 public static function isElementorEditor()
1616 1909 {
1617 1910 return defined('ELEMENTOR_VERSION') &&
1618 1911 class_exists('\Elementor\Plugin') &&
@@ -1622,8 +1915,9 @@
1622 1915
1623 1916 /**
1624 1917 * Check if we're in block editor context (Site Editor, Template Editor, or Post/Page Editor)
1625 1918 * Covers all Gutenberg block editor contexts including mobile/tablet preview iframes
1919 + *
1626 1920 * @return bool
1627 1921 */
1628 1922 public static function isSiteEditor()
1629 1923 {
@@ -1652,9 +1946,9 @@
1652 1946 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- REQUEST_URI used for string comparison only
1653 1947 $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
1654 1948 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking block editor context
1655 1949 return isset( $_GET['_wp-find-template'] ) ||
1656 - strpos( $request_uri, 'site-editor.php' ) !== false ||
1950 + strpos( $request_uri, 'site-editor.php' ) !== false ||
1657 1951 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking REST API context
1658 - (defined('REST_REQUEST') && REST_REQUEST && !empty($_REQUEST['context']) && $_REQUEST['context'] === 'edit');
1952 + (defined('REST_REQUEST') && REST_REQUEST && !empty($_REQUEST['context']) && 'edit' === $_REQUEST['context']);
1659 1953 }
1660 1954 }