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 +232 -11 6.2.96.2.14 View file →
@@ -91,8 +91,78 @@
91 91 && ArrayHelper::get($option, 'type') === 'group'
92 92 && is_array(ArrayHelper::get($option, 'options'));
93 93 }
94 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 +
95 165 public static function sanitizeAdvancedOptions($options, $depth = 0)
96 166 {
97 167 if (!is_array($options)) {
98 168 return [];
@@ -115,23 +185,56 @@
115 185 $sanitized = array_merge($sanitized, $groupOptions);
116 186 continue;
117 187 }
118 188
119 - $sanitized[] = [
189 + $groupLabel = ArrayHelper::get($option, 'label', '');
190 +
191 + $group = [
120 192 'type' => 'group',
121 - 'label' => wp_kses_post(ArrayHelper::get($option, 'label', '')),
193 + 'label' => wp_kses_post(is_scalar($groupLabel) ? $groupLabel : ''),
122 194 'options' => $groupOptions,
123 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;
124 207 continue;
125 208 }
126 209
127 - $sanitized[] = [
128 - 'label' => wp_kses_post(ArrayHelper::get($option, 'label', '')),
129 - 'value' => sanitize_text_field(ArrayHelper::get($option, 'value', '')),
130 - 'image' => sanitize_url(ArrayHelper::get($option, 'image', '')),
131 - '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')),
132 227 'disabled' => ArrayHelper::isTrue($option, 'disabled'),
133 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;
134 237 }
135 238
136 239 return $sanitized;
137 240 }
@@ -243,13 +346,21 @@
243 346 );
244 347
245 348 $statuses = apply_filters('fluentform/entry_statuses_core', $statuses, $form_id);
246 349
247 - $statuses['trashed'] = 'Trashed';
350 + $statuses['spam'] = __('Spam', 'fluentform');
248 351
352 + $statuses['trashed'] = __('Trashed', 'fluentform');
353 +
249 354 return $statuses;
250 355 }
251 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 +
252 363 public static function getReportableInputs()
253 364 {
254 365 $data = [
255 366 'select',
@@ -986,9 +1097,9 @@
986 1097 }
987 1098
988 1099 public static function sanitizeForCSV($content)
989 1100 {
990 - $formulas = ['=', '-', '+', '@', "\t", "\r"];
1101 + $formulas = ['=', '-', '+', '@', "\t", "\r", "\n"];
991 1102
992 1103 $formulas = apply_filters('fluentform/csv_sanitize_formulas', $formulas);
993 1104
994 1105 if (Str::startsWith($content, $formulas)) {
@@ -1348,9 +1459,9 @@
1348 1459 case 'select_country':
1349 1460 $fieldData = ArrayHelper::get($field, 'raw');
1350 1461 $data = (new SelectCountry())->loadCountries($fieldData);
1351 1462 $validCountries = ArrayHelper::get($fieldData, 'settings.country_list.priority_based', []);
1352 - $validCountries = array_merge($validCountries, array_keys(ArrayHelper::get($data, 'options')));
1463 + $validCountries = array_merge($validCountries, array_keys((array) ArrayHelper::get($data, 'options', [])));
1353 1464 $isValid = in_array($inputValue, $validCountries);
1354 1465 break;
1355 1466 case 'repeater_field':
1356 1467 case 'repeater_container':
@@ -1396,8 +1507,111 @@
1396 1507 return $error;
1397 1508 }
1398 1509
1399 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 + /**
1400 1614 * Prefix used to store a checkable field's "Other" option value,
1401 1615 * built from the field's own (translated) label. Pass $form to run
1402 1616 * the field through the rendering filter (translation plugins) first.
1403 1617 *
@@ -1607,10 +1821,17 @@
1607 1821 {
1608 1822 return home_url($args);
1609 1823 }
1610 1824
1611 - public static function getCountryCodeFromHeaders()
1825 + public static function getCountryCodeFromHeaders($forRestriction = false)
1612 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 +
1613 1834 $headers = [
1614 1835 // Cloudflare (most common)
1615 1836 'HTTP_CF_IPCOUNTRY',
1616 1837 'CF-IPCountry',