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 +260 -11 6.2.86.2.14 View file →
@@ -56,8 +56,36 @@
56 56
57 57 return $input;
58 58 }
59 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 +
60 88 public static function isOptionGroup($option)
61 89 {
62 90 return is_array($option)
63 91 && ArrayHelper::get($option, 'type') === 'group'
@@ -63,8 +91,78 @@
63 91 && ArrayHelper::get($option, 'type') === 'group'
64 92 && is_array(ArrayHelper::get($option, 'options'));
65 93 }
66 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 +
67 165 public static function sanitizeAdvancedOptions($options, $depth = 0)
68 166 {
69 167 if (!is_array($options)) {
70 168 return [];
@@ -87,23 +185,56 @@
87 185 $sanitized = array_merge($sanitized, $groupOptions);
88 186 continue;
89 187 }
90 188
91 - $sanitized[] = [
189 + $groupLabel = ArrayHelper::get($option, 'label', '');
190 +
191 + $group = [
92 192 'type' => 'group',
93 - 'label' => wp_kses_post(ArrayHelper::get($option, 'label', '')),
193 + 'label' => wp_kses_post(is_scalar($groupLabel) ? $groupLabel : ''),
94 194 'options' => $groupOptions,
95 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;
96 207 continue;
97 208 }
98 209
99 - $sanitized[] = [
100 - 'label' => wp_kses_post(ArrayHelper::get($option, 'label', '')),
101 - 'value' => sanitize_text_field(ArrayHelper::get($option, 'value', '')),
102 - 'image' => sanitize_url(ArrayHelper::get($option, 'image', '')),
103 - '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')),
104 227 'disabled' => ArrayHelper::isTrue($option, 'disabled'),
105 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;
106 237 }
107 238
108 239 return $sanitized;
109 240 }
@@ -215,13 +346,21 @@
215 346 );
216 347
217 348 $statuses = apply_filters('fluentform/entry_statuses_core', $statuses, $form_id);
218 349
219 - $statuses['trashed'] = 'Trashed';
350 + $statuses['spam'] = __('Spam', 'fluentform');
220 351
352 + $statuses['trashed'] = __('Trashed', 'fluentform');
353 +
221 354 return $statuses;
222 355 }
223 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 +
224 363 public static function getReportableInputs()
225 364 {
226 365 $data = [
227 366 'select',
@@ -958,9 +1097,9 @@
958 1097 }
959 1098
960 1099 public static function sanitizeForCSV($content)
961 1100 {
962 - $formulas = ['=', '-', '+', '@', "\t", "\r"];
1101 + $formulas = ['=', '-', '+', '@', "\t", "\r", "\n"];
963 1102
964 1103 $formulas = apply_filters('fluentform/csv_sanitize_formulas', $formulas);
965 1104
966 1105 if (Str::startsWith($content, $formulas)) {
@@ -1320,9 +1459,9 @@
1320 1459 case 'select_country':
1321 1460 $fieldData = ArrayHelper::get($field, 'raw');
1322 1461 $data = (new SelectCountry())->loadCountries($fieldData);
1323 1462 $validCountries = ArrayHelper::get($fieldData, 'settings.country_list.priority_based', []);
1324 - $validCountries = array_merge($validCountries, array_keys(ArrayHelper::get($data, 'options')));
1463 + $validCountries = array_merge($validCountries, array_keys((array) ArrayHelper::get($data, 'options', [])));
1325 1464 $isValid = in_array($inputValue, $validCountries);
1326 1465 break;
1327 1466 case 'repeater_field':
1328 1467 case 'repeater_container':
@@ -1368,8 +1507,111 @@
1368 1507 return $error;
1369 1508 }
1370 1509
1371 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 + /**
1372 1614 * Prefix used to store a checkable field's "Other" option value,
1373 1615 * built from the field's own (translated) label. Pass $form to run
1374 1616 * the field through the rendering filter (translation plugins) first.
1375 1617 *
@@ -1579,10 +1821,17 @@
1579 1821 {
1580 1822 return home_url($args);
1581 1823 }
1582 1824
1583 - public static function getCountryCodeFromHeaders()
1825 + public static function getCountryCodeFromHeaders($forRestriction = false)
1584 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 +
1585 1834 $headers = [
1586 1835 // Cloudflare (most common)
1587 1836 'HTTP_CF_IPCOUNTRY',
1588 1837 'CF-IPCountry',