PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.15
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.15
6.2.15 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 All 197 releases
← All changes | app/Helpers/Helper.php +220 -8 6.2.12 → 6.2.15 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 }
@@ -250,8 +353,14 @@
250 353
251 354 return $statuses;
252 355 }
253 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 +
254 363 public static function getReportableInputs()
255 364 {
256 365 $data = [
257 366 'select',
@@ -1261,9 +1370,9 @@
1261 1370 }
1262 1371 } elseif ('dynamic_field' == $fieldType) {
1263 1372 $dynamicFetchValue = 'yes' == ArrayHelper::get($rawField, 'settings.dynamic_fetch');
1264 1373 if ($dynamicFetchValue) {
1265 - $rawField = apply_filters('fluentform/dynamic_field_re_fetch_result_and_resolve_value', $rawField);
1374 + $rawField = apply_filters('fluentform/dynamic_field_re_fetch_result_and_resolve_value', $rawField, $form->id);
1266 1375 }
1267 1376 $dfElementType = ArrayHelper::get($rawField, 'attributes.type');
1268 1377 if (in_array($dfElementType, ['radio', 'select', 'checkbox'])) {
1269 1378 $fieldType = 'dynamic_field_options';
@@ -1395,8 +1504,111 @@
1395 1504 $error = __('The given data was invalid', 'fluentform');
1396 1505 }
1397 1506 }
1398 1507 return $error;
1508 + }
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);
1399 1611 }
1400 1612
1401 1613 /**
1402 1614 * Prefix used to store a checkable field's "Other" option value,