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 +401 -90 6.2.46.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 }
@@ -309,8 +449,25 @@
309 449 return null;
310 450 }
311 451 }
312 452
453 + /**
454 + * Resolve an entry's column => value map regardless of whether the entry is
455 + * a stdClass DB row (columns are real properties) or a WPFluent Model
456 + * (columns live in an internal attribute bag reached via __get).
457 + *
458 + * @param object|array $entry
459 + * @return array
460 + */
461 + public static function getEntryColumns($entry)
462 + {
463 + if (is_object($entry) && method_exists($entry, 'getAttributes')) {
464 + return $entry->getAttributes();
465 + }
466 +
467 + return (array) $entry;
468 + }
469 +
313 470 public static function getSubmissionMeta($submissionId, $metaKey, $default = false)
314 471 {
315 472 return SubmissionMeta::retrieve($metaKey, $submissionId, $default);
316 473 }
@@ -373,9 +530,9 @@
373 530 }
374 531
375 532 public static function getFormInstaceClass($formId)
376 533 {
377 - static::$formInstance += 1;
534 + static::$formInstance++;
378 535
379 536 return 'ff_form_instance_' . $formId . '_' . static::$formInstance;
380 537 }
381 538
@@ -393,9 +550,9 @@
393 550 'fluent_forms_settings',
394 551 'fluent_forms_add_ons',
395 552 'fluent_forms_docs',
396 553 'fluent_forms_payment_entries',
397 - 'fluent_forms_reports'
554 + 'fluent_forms_reports',
398 555 ];
399 556
400 557 $status = true;
401 558
@@ -407,9 +564,9 @@
407 564
408 565 $status = apply_filters_deprecated(
409 566 'fluentform_is_admin_page',
410 567 [
411 - $status
568 + $status,
412 569 ],
413 570 FLUENTFORM_FRAMEWORK_UPGRADE,
414 571 'fluentform/is_admin_page',
415 572 'Use fluentform/is_admin_page instead of fluentform_is_admin_page.'
@@ -439,9 +596,9 @@
439 596
440 597 $result = shortcode_parse_atts($parsedCode);
441 598
442 599 if (!empty($result[$selector])) {
443 - if ($tag == 'fluentform' && !empty($result['type']) && $result['type'] == 'conversational') {
600 + if ('fluentform' == $tag && !empty($result['type']) && 'conversational' == $result['type']) {
444 601 continue;
445 602 }
446 603
447 604 $ids[$result[$selector]] = $result[$selector];
@@ -450,9 +607,9 @@
450 607
451 608 if ($theme) {
452 609 $attributes[] = [
453 610 'formId' => $result[$selector],
454 - 'theme' => $theme
611 + 'theme' => $theme,
455 612 ];
456 613 }
457 614 }
458 615 }
@@ -473,9 +630,9 @@
473 630 if (!function_exists('parse_blocks')) {
474 631 return $ids;
475 632 }
476 633
477 - $has_block = false !== strpos($content, '<!-- wp:fluentfom/guten-block' . ' ');
634 + $has_block = false !== strpos($content, '<!-- wp:fluentfom/guten-block ');
478 635
479 636 if (!$has_block) {
480 637 return $ids;
481 638 }
@@ -487,9 +644,9 @@
487 644 }
488 645
489 646 $hasBlock = strpos($block['blockName'], 'fluentfom/guten-block') === 0;
490 647 if ($hasBlock) {
491 - $formId = (int)$block['attrs']['formId'];
648 + $formId = (int) $block['attrs']['formId'];
492 649
493 650 $ids[] = $formId;
494 651
495 652 $theme = ArrayHelper::get($block, 'attrs.themeStyle');
@@ -496,9 +653,9 @@
496 653
497 654 if ($theme) {
498 655 $attributes[] = [
499 656 'formId' => $formId,
500 - 'theme' => $theme
657 + 'theme' => $theme,
501 658 ];
502 659 }
503 660 }
504 661 }
@@ -532,10 +689,10 @@
532 689 if (!$form) {
533 690 return false;
534 691 }
535 692
536 - $fieldsJson = (string)($form->form_fields ?? '');
537 - if ($fieldsJson === '') {
693 + $fieldsJson = (string) ($form->form_fields ?? '');
694 + if ('' === $fieldsJson) {
538 695 return false;
539 696 }
540 697
541 698 $fields = json_decode($fieldsJson, true);
@@ -542,9 +699,9 @@
542 699 if (!is_array($fields)) {
543 700 return false;
544 701 }
545 702
546 - return (bool)ArrayHelper::get($fields, 'stepsWrapper');
703 + return (bool) ArrayHelper::get($fields, 'stepsWrapper');
547 704 }
548 705
549 706 public static function hasFormElement($formId, $elementName)
550 707 {
@@ -566,10 +723,10 @@
566 723
567 724 // if form has pending payment then the value doesn't exist in EntryDetails table
568 725 // further checking on Submission table if the value exists
569 726 if (!$exist && $form->has_payment) {
570 - $escapedKey = json_encode($fieldName);
571 - $escapedValue = json_encode($inputValue);
727 + $escapedKey = wp_json_encode($fieldName);
728 + $escapedValue = wp_json_encode($inputValue);
572 729 $searchPattern = trim($escapedKey, '"') . '":' . $escapedValue;
573 730 $searchPattern = addcslashes($searchPattern, '%_');
574 731
575 732 $exist = Submission::where('form_id', $form->id)
@@ -654,9 +811,9 @@
654 811
655 812 $data = apply_filters_deprecated(
656 813 'fluentform_numeric_styles',
657 814 [
658 - $data
815 + $data,
659 816 ],
660 817 FLUENTFORM_FRAMEWORK_UPGRADE,
661 818 'fluentform/numeric_styles',
662 819 'Use fluentform/numeric_styles instead of fluentform_numeric_styles.'
@@ -735,12 +892,10 @@
735 892 foreach ($columns as $column) {
736 893 $columnInputs = static::getFieldNamesStatuses(ArrayHelper::get($column, 'fields', []));
737 894 $names = array_merge($names, $columnInputs);
738 895 }
739 - } else {
740 - if ($name = ArrayHelper::get($field, 'attributes.name')) {
896 + } elseif ($name = ArrayHelper::get($field, 'attributes.name')) {
741 897 $names[] = $name;
742 - }
743 898 }
744 899 }
745 900
746 901 return $names;
@@ -789,16 +944,20 @@
789 944 $optionValues = array_values(array_filter(array_map(
790 945 'sanitize_text_field',
791 946 array_column(static::flattenAdvancedOptions($formattedOptions), 'value')
792 947 ), function ($value) {
793 - return $value !== '';
948 + return '' !== $value;
794 949 }));
795 950
796 951 if (count($optionValues) !== count(array_unique($optionValues))) {
797 - $duplicates[] = ArrayHelper::get($field, 'settings.admin_field_label')
798 - ?: ArrayHelper::get($field, 'settings.label')
799 - ?: ArrayHelper::get($field, 'attributes.name')
800 - ?: __('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');
801 960 }
802 961 }
803 962
804 963 return $duplicates;
@@ -821,12 +980,10 @@
821 980 if ('conversational' == $type) {
822 981 return static::getConversionUrl($formId);
823 982 } elseif ('classic' == $type) {
824 983 return site_url('?fluent_forms_pages=1&design_mode=1&preview_id=' . $formId) . '#ff_preview';
825 - } else {
826 - if (static::isConversionForm($formId)) {
984 + } elseif (static::isConversionForm($formId)) {
827 985 return static::getConversionUrl($formId);
828 - }
829 986 }
830 987
831 988 return site_url('?fluent_forms_pages=1&design_mode=1&preview_id=' . $formId) . '#ff_preview';
832 989 }
@@ -852,9 +1009,9 @@
852 1009
853 1010 $slug = apply_filters_deprecated(
854 1011 'fluentform_conversational_url_slug',
855 1012 [
856 - 'fluent-form'
1013 + 'fluent-form',
857 1014 ],
858 1015 FLUENTFORM_FRAMEWORK_UPGRADE,
859 1016 'fluentform/conversational_url_slug',
860 1017 'Use fluentform/conversational_url_slug instead of fluentform_conversational_url_slug.'
@@ -886,9 +1043,9 @@
886 1043
887 1044 $locations = apply_filters_deprecated(
888 1045 'fluentform_file_upload_options',
889 1046 [
890 - $locations
1047 + $locations,
891 1048 ],
892 1049 FLUENTFORM_FRAMEWORK_UPGRADE,
893 1050 'fluentform/file_upload_options',
894 1051 'Use fluentform/file_upload_options instead of fluentform_file_upload_options'
@@ -940,9 +1097,9 @@
940 1097 }
941 1098
942 1099 public static function sanitizeForCSV($content)
943 1100 {
944 - $formulas = ['=', '-', '+', '@', "\t", "\r"];
1101 + $formulas = ['=', '-', '+', '@', "\t", "\r", "\n"];
945 1102
946 1103 $formulas = apply_filters('fluentform/csv_sanitize_formulas', $formulas);
947 1104
948 1105 if (Str::startsWith($content, $formulas)) {
@@ -971,9 +1128,9 @@
971 1128 $isTruncate = apply_filters_deprecated(
972 1129 'fluentform_truncate_password_values',
973 1130 [
974 1131 true,
975 - $formId
1132 + $formId,
976 1133 ],
977 1134 FLUENTFORM_FRAMEWORK_UPGRADE,
978 1135 'fluentform/truncate_password_values',
979 1136 'Use fluentform/truncate_password_values instead of fluentform_truncate_password_values.'
@@ -993,10 +1150,9 @@
993 1150 $field,
994 1151 $rowJoiner = '<br />',
995 1152 $colJoiner = ', ',
996 1153 $type = ''
997 - )
998 - {
1154 + ) {
999 1155 if (!$girdData || !$field) {
1000 1156 return '';
1001 1157 }
1002 1158 $girdRows = ArrayHelper::get($field, 'raw.settings.grid_rows', []);
@@ -1021,9 +1177,9 @@
1021 1177 $_colJoiner = $colJoiner;
1022 1178 if ($girdCols && isset($girdCols[$item])) {
1023 1179 $item = $girdCols[$item];
1024 1180 }
1025 - if ($index == (count($column) - 1)) {
1181 + if ((count($column) - 1) == $index) {
1026 1182 $_colJoiner = '';
1027 1183 }
1028 1184 $value .= $item . $_colJoiner;
1029 1185 }
@@ -1098,9 +1254,9 @@
1098 1254
1099 1255 public static function isAutosaveEnabled()
1100 1256 {
1101 1257 $autosaveEnabled = ArrayHelper::get(get_option('_fluentform_global_form_settings'), 'misc.autosave_enabled', 'no');
1102 - return $autosaveEnabled === 'yes';
1258 + return 'yes' === $autosaveEnabled;
1103 1259 }
1104 1260
1105 1261 public static function maybeDecryptUrl($url)
1106 1262 {
@@ -1126,9 +1282,9 @@
1126 1282
1127 1283 public static function isBlockEditor()
1128 1284 {
1129 1285 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking REST API context
1130 - 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'];
1131 1287 }
1132 1288
1133 1289 public static function resolveValidationRulesGlobalOption(&$field)
1134 1290 {
@@ -1135,16 +1291,14 @@
1135 1291 if (isset($field['fields']) && is_array($field['fields'])) {
1136 1292 foreach ($field['fields'] as &$subField) {
1137 1293 static::resolveValidationRulesGlobalOption($subField);
1138 1294 }
1139 - } else {
1140 - if (ArrayHelper::get($field, 'settings.validation_rules')) {
1141 - foreach ($field['settings']['validation_rules'] as $key => &$rule) {
1142 - if (!isset($rule['global'])) {
1143 - $rule['global'] = false;
1144 - }
1145 - $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;
1146 1299 }
1300 + $rule['global_message'] = static::getGlobalDefaultMessage($key);
1147 1301 }
1148 1302 }
1149 1303 }
1150 1304
@@ -1179,9 +1333,10 @@
1179 1333 }
1180 1334 $fieldType = ArrayHelper::get($rawField, 'element');
1181 1335 $rawField = apply_filters('fluentform/rendering_field_data_' . $fieldType, $rawField, $form);
1182 1336 $options = [];
1183 - if ("net_promoter_score" === $fieldType) {
1337 + $otherPrefix = '';
1338 + if ('net_promoter_score' === $fieldType) {
1184 1339 $options = array_flip(ArrayHelper::get($rawField, 'options', []));
1185 1340 } elseif ('ratings' == $fieldType) {
1186 1341 $options = array_keys(ArrayHelper::get($rawField, 'options', []));
1187 1342 } elseif ('gdpr_agreement' == $fieldType) {
@@ -1204,16 +1359,17 @@
1204 1359 // @todo : Update all reference in form templates
1205 1360 }
1206 1361
1207 1362 $options = array_column(self::flattenAdvancedOptions($formattedOptions), 'value');
1208 -
1363 +
1209 1364 // Add field-specific __ff_other__ to options if "Other" option is enabled
1210 1365 if (in_array($fieldType, ['input_checkbox', 'input_radio']) &&
1211 1366 ArrayHelper::get($rawField, 'settings.enable_other_option') === 'yes') {
1212 1367 $fieldName = sanitize_key(str_replace(['[', ']'], '', ArrayHelper::get($rawField, 'attributes.name', '')));
1213 1368 $options[] = '__ff_other_' . $fieldName . '__';
1369 + $otherPrefix = static::getOtherOptionValuePrefix($rawField);
1214 1370 }
1215 - } elseif ("dynamic_field" == $fieldType) {
1371 + } elseif ('dynamic_field' == $fieldType) {
1216 1372 $dynamicFetchValue = 'yes' == ArrayHelper::get($rawField, 'settings.dynamic_fetch');
1217 1373 if ($dynamicFetchValue) {
1218 1374 $rawField = apply_filters('fluentform/dynamic_field_re_fetch_result_and_resolve_value', $rawField);
1219 1375 }
@@ -1244,13 +1400,13 @@
1244 1400 break;
1245 1401 }
1246 1402
1247 1403 $filteredValues = array_values(array_filter(array_map('sanitize_text_field', $inputValue), function ($value) {
1248 - return $value !== '';
1404 + return '' !== $value;
1249 1405 }));
1250 1406
1251 1407 $normalizedOptions = array_values(array_filter(array_map('sanitize_text_field', $options), function ($value) {
1252 - return $value !== '';
1408 + return '' !== $value;
1253 1409 }));
1254 1410
1255 1411 sort($filteredValues);
1256 1412 sort($normalizedOptions);
@@ -1267,30 +1423,28 @@
1267 1423 case 'terms_and_condition':
1268 1424 case 'input_checkbox':
1269 1425 case 'multi_select':
1270 1426 case 'dynamic_field_options':
1271 -
1272 1427 $skipValidationInputsWithOptions = apply_filters('fluentform/skip_validation_inputs_with_options', false, $fieldType, $form, $formData);
1273 1428 if ($skipValidationInputsWithOptions) {
1274 1429 break;
1275 1430 }
1276 1431 if (is_array($inputValue)) {
1277 - // Handle field-specific "Other" options for checkboxes
1278 - $filteredValues = array_filter($inputValue, function($value) {
1279 - // 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) {
1280 1434 return !preg_match('/^__ff_other_.*__$/', $value) &&
1281 - !preg_match('/^Other:\s/', $value);
1435 + !preg_match('/^Other:\s/', $value) &&
1436 + !($otherPrefix && 0 === strpos($value, $otherPrefix));
1282 1437 });
1283 1438 $isValid = array_diff($filteredValues, $options);
1284 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;
1285 1445 } else {
1286 - // Handle field-specific "Other" option for single values
1287 - if (preg_match('/^__ff_other_.*__$/', $inputValue) ||
1288 - preg_match('/^Other:\s/', $inputValue)) {
1289 - $isValid = true;
1290 - } else {
1291 - $isValid = in_array($inputValue, $options);
1292 - }
1446 + $isValid = in_array($inputValue, $options);
1293 1447 }
1294 1448 break;
1295 1449 case 'input_number':
1296 1450 if (is_array($inputValue)) {
@@ -1305,9 +1459,9 @@
1305 1459 case 'select_country':
1306 1460 $fieldData = ArrayHelper::get($field, 'raw');
1307 1461 $data = (new SelectCountry())->loadCountries($fieldData);
1308 1462 $validCountries = ArrayHelper::get($fieldData, 'settings.country_list.priority_based', []);
1309 - $validCountries = array_merge($validCountries, array_keys(ArrayHelper::get($data, 'options')));
1463 + $validCountries = array_merge($validCountries, array_keys((array) ArrayHelper::get($data, 'options', [])));
1310 1464 $isValid = in_array($inputValue, $validCountries);
1311 1465 break;
1312 1466 case 'repeater_field':
1313 1467 case 'repeater_container':
@@ -1352,8 +1506,137 @@
1352 1506 }
1353 1507 return $error;
1354 1508 }
1355 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 +
1356 1639 public static function getWhiteListedFields($formId)
1357 1640 {
1358 1641 $whiteListedFields = [
1359 1642 '__fluent_form_embded_post_id',
@@ -1367,9 +1650,9 @@
1367 1650 '__entry_intermediate_hash',
1368 1651 '__square_payment_method_id',
1369 1652 '__square_verify_buyer_id',
1370 1653 'ct_bot_detector_event_token',
1371 - 'ff_ct_form_load_time'
1654 + 'ff_ct_form_load_time',
1372 1655 ];
1373 1656
1374 1657 return apply_filters('fluentform/white_listed_fields', $whiteListedFields, $formId);
1375 1658 }
@@ -1375,8 +1658,9 @@
1375 1658 }
1376 1659
1377 1660 /**
1378 1661 * Shortcode parse on validation message
1662 + *
1379 1663 * @param string $message
1380 1664 * @param object $form
1381 1665 * @param string $fieldName
1382 1666 * @return string
@@ -1383,9 +1667,9 @@
1383 1667 */
1384 1668 public static function shortCodeParseOnValidationMessage($message, $form, $fieldName)
1385 1669 {
1386 1670 // Return early if form is null to prevent errors
1387 - if ($form === null) {
1671 + if (null === $form) {
1388 1672 return $message;
1389 1673 }
1390 1674
1391 1675 // For validation message there is no entry & form data
@@ -1391,9 +1675,9 @@
1391 1675 // For validation message there is no entry & form data
1392 1676 // Add 'current_field' name as data array to resolve {labels.current_field} shortcode if it has
1393 1677 return ShortCodeParser::parse(
1394 1678 $message,
1395 - (object)['response' => "", 'form_id' => $form->id],
1679 + (object) ['response' => '', 'form_id' => $form->id],
1396 1680 ['current_field' => $fieldName],
1397 1681 $form
1398 1682 );
1399 1683 }
@@ -1491,8 +1775,25 @@
1491 1775 {
1492 1776 return defined('FLUENTFORMPRO');
1493 1777 }
1494 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 +
1495 1796 public static function getLandingPageEnabledForms()
1496 1797 {
1497 1798 if (class_exists(\FluentFormPro\classes\SharePage\SharePage::class)) {
1498 1799 if (method_exists(\FluentFormPro\classes\SharePage\SharePage::class, 'getLandingPageFormIds')) {
@@ -1520,10 +1821,17 @@
1520 1821 {
1521 1822 return home_url($args);
1522 1823 }
1523 1824
1524 - public static function getCountryCodeFromHeaders()
1825 + public static function getCountryCodeFromHeaders($forRestriction = false)
1525 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 +
1526 1834 $headers = [
1527 1835 // Cloudflare (most common)
1528 1836 'HTTP_CF_IPCOUNTRY',
1529 1837 'CF-IPCountry',
@@ -1547,9 +1855,9 @@
1547 1855 // General purpose country headers
1548 1856 'HTTP_X_COUNTRY',
1549 1857 'X-Country',
1550 1858 'HTTP_X_COUNTRY_ISO',
1551 - 'X-Country-ISO'
1859 + 'X-Country-ISO',
1552 1860 ];
1553 1861
1554 1862 foreach ($headers as $header) {
1555 1863 // Try directly from $_SERVER
@@ -1555,10 +1863,10 @@
1555 1863 // Try directly from $_SERVER
1556 1864 if (isset($_SERVER[$header])) {
1557 1865 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Country code from CDN/proxy header, validated below
1558 1866 $code = trim(sanitize_text_field(wp_unslash($_SERVER[$header])));
1559 - } // Try with HTTP_ prefix if not already present
1560 - elseif (strpos($header, 'HTTP_') !== 0) {
1867 + } elseif (strpos($header, 'HTTP_') !== 0) {
1868 + // Try with HTTP_ prefix if not already present
1561 1869 $httpHeader = 'HTTP_' . str_replace('-', '_', strtoupper($header));
1562 1870 if (isset($_SERVER[$httpHeader])) {
1563 1871 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Country code from CDN/proxy header, validated below
1564 1872 $code = trim(sanitize_text_field(wp_unslash($_SERVER[$httpHeader])));
@@ -1569,9 +1877,9 @@
1569 1877 continue;
1570 1878 }
1571 1879
1572 1880 // Basic validation - should be 2-letter country code
1573 - 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) {
1574 1882 return strtoupper($code);
1575 1883 }
1576 1884 }
1577 1885
@@ -1579,8 +1887,9 @@
1579 1887 }
1580 1888
1581 1889 /**
1582 1890 * Fixes PHP Object Injection Vulnerability
1891 + *
1583 1892 * @param $data
1584 1893 * @return mixed
1585 1894 */
1586 1895 public static function safeUnserialize($data)
@@ -1590,12 +1899,13 @@
1590 1899 }
1591 1900 return $data;
1592 1901 }
1593 1902
1594 - /**
1595 - * If elementor editor is open
1596 - * @return bool
1597 - */
1903 + /**
1904 + * If elementor editor is open
1905 + *
1906 + * @return bool
1907 + */
1598 1908 public static function isElementorEditor()
1599 1909 {
1600 1910 return defined('ELEMENTOR_VERSION') &&
1601 1911 class_exists('\Elementor\Plugin') &&
@@ -1605,8 +1915,9 @@
1605 1915
1606 1916 /**
1607 1917 * Check if we're in block editor context (Site Editor, Template Editor, or Post/Page Editor)
1608 1918 * Covers all Gutenberg block editor contexts including mobile/tablet preview iframes
1919 + *
1609 1920 * @return bool
1610 1921 */
1611 1922 public static function isSiteEditor()
1612 1923 {
@@ -1635,9 +1946,9 @@
1635 1946 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- REQUEST_URI used for string comparison only
1636 1947 $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
1637 1948 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking block editor context
1638 1949 return isset( $_GET['_wp-find-template'] ) ||
1639 - strpos( $request_uri, 'site-editor.php' ) !== false ||
1950 + strpos( $request_uri, 'site-editor.php' ) !== false ||
1640 1951 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking REST API context
1641 - (defined('REST_REQUEST') && REST_REQUEST && !empty($_REQUEST['context']) && $_REQUEST['context'] === 'edit');
1952 + (defined('REST_REQUEST') && REST_REQUEST && !empty($_REQUEST['context']) && 'edit' === $_REQUEST['context']);
1642 1953 }
1643 1954 }