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/Services/Transfer/TransferService.php +254 -36 6.2.10 → 6.2.15 View file →
@@ -3,8 +3,9 @@
3 3 namespace FluentForm\App\Services\Transfer;
4 4
5 5 defined('ABSPATH') or die;
6 6
7 +use FluentForm\App\Services\FormBuilder\AutocompleteTokens;
7 8 use Exception;
8 9 use FluentForm\App\Helpers\Helper;
9 10 use FluentForm\App\Models\Form;
10 11 use FluentForm\App\Models\FormMeta;
@@ -13,8 +14,9 @@
13 14 use FluentForm\App\Modules\Acl\Acl;
14 15 use FluentForm\App\Modules\Form\FormDataParser;
15 16 use FluentForm\App\Modules\Form\FormFieldsParser;
16 17 use FluentForm\App\Services\FormBuilder\ShortCodeParser;
18 +use FluentForm\App\Services\FormBuilder\DateConfigPolicy;
17 19 use FluentForm\Framework\Foundation\App;
18 20 use FluentForm\Framework\Http\Request\File;
19 21 use FluentForm\Framework\Support\Arr;
20 22
@@ -46,14 +48,24 @@
46 48
47 49 private static function sanitizeJsonNode(&$node)
48 50 {
49 51 if (is_array($node)) {
50 - foreach ($node as &$value) {
52 + foreach ($node as $key => &$value) {
53 + if ('attributes' === $key) {
54 + $value = self::dropEventHandlerAttributeKeys($value);
55 + $value = self::sanitizeFieldAttributes($value);
56 + }
57 + $value = self::sanitizeAttributeControlSetting($key, $value);
51 58 self::sanitizeJsonNode($value);
52 59 }
53 60 unset($value);
54 61 } elseif (is_object($node)) {
55 62 foreach (get_object_vars($node) as $key => $value) {
63 + if ('attributes' === $key) {
64 + $value = self::dropEventHandlerAttributeKeys($value);
65 + $value = self::sanitizeFieldAttributes($value);
66 + }
67 + $value = self::sanitizeAttributeControlSetting($key, $value);
56 68 self::sanitizeJsonNode($value);
57 69 $node->{$key} = $value;
58 70 }
59 71 } elseif (is_string($node)) {
@@ -60,8 +72,110 @@
60 72 $node = wp_kses_post($node);
61 73 }
62 74 }
63 75
76 + // Scoped to a field's own attributes: sanitizeJsonNode() walks the whole meta
77 + // tree, so matching on key name alone would rewrite any unrelated property
78 + // that happens to be called autocomplete.
79 + private static function sanitizeFieldAttributes($attributes)
80 + {
81 + if (is_object($attributes) && property_exists($attributes, 'autocomplete')) {
82 + $attributes->autocomplete = AutocompleteTokens::sanitize($attributes->autocomplete);
83 + } elseif (is_array($attributes) && array_key_exists('autocomplete', $attributes)) {
84 + $attributes['autocomplete'] = AutocompleteTokens::sanitize($attributes['autocomplete']);
85 + }
86 +
87 + return $attributes;
88 + }
89 +
90 + private static function sanitizeAttributeControlSetting($key, $value)
91 + {
92 + if ('max_repeat_field' === $key) {
93 + return is_scalar($value) && '' !== trim((string) $value) ? absint($value) : '';
94 + }
95 +
96 + if ('display_mode' === $key) {
97 + $mode = is_scalar($value) ? sanitize_key((string) $value) : '';
98 + return in_array($mode, ['accordion', 'tabs'], true) ? $mode : 'accordion';
99 + }
100 +
101 + if ('display_type' === $key) {
102 + return is_scalar($value) ? sanitize_html_class((string) $value) : '';
103 + }
104 +
105 + if ('subscription_options' === $key && is_array($value)) {
106 + foreach ($value as &$option) {
107 + if (!is_array($option)) {
108 + continue;
109 + }
110 +
111 + foreach (['name', 'user_input_label'] as $labelKey) {
112 + if (!array_key_exists($labelKey, $option)) {
113 + continue;
114 + }
115 +
116 + $label = $option[$labelKey];
117 + $option[$labelKey] = is_scalar($label) ? fluentform_sanitize_html((string) $label) : '';
118 + }
119 + }
120 + unset($option);
121 +
122 + return $value;
123 + }
124 +
125 + if ('pricing_options' !== $key || !is_array($value)) {
126 + return $value;
127 + }
128 +
129 + foreach ($value as &$option) {
130 + if (!is_array($option)) {
131 + continue;
132 + }
133 +
134 + if (array_key_exists('label', $option)) {
135 + $label = $option['label'];
136 + $option['label'] = is_scalar($label) ? fluentform_sanitize_html((string) $label) : '';
137 + }
138 +
139 + if (array_key_exists('image', $option)) {
140 + $image = $option['image'];
141 + $option['image'] = is_scalar($image) ? esc_url_raw((string) $image) : '';
142 + }
143 + }
144 + unset($option);
145 +
146 + return $value;
147 + }
148 +
149 + /**
150 + * kses cleans string values only, so an `onfocus` KEY survives an import untouched.
151 + * Shares the Helper rule so the two write paths cannot drift apart.
152 + */
153 + private static function dropEventHandlerAttributeKeys($attributes)
154 + {
155 + if (!is_array($attributes) && !is_object($attributes)) {
156 + return $attributes;
157 + }
158 +
159 + $keys = is_object($attributes)
160 + ? array_keys(get_object_vars($attributes))
161 + : array_keys($attributes);
162 +
163 + foreach ($keys as $key) {
164 + if (Helper::isSafeAttributeKey($key)) {
165 + continue;
166 + }
167 +
168 + if (is_object($attributes)) {
169 + unset($attributes->{$key});
170 + } else {
171 + unset($attributes[$key]);
172 + }
173 + }
174 +
175 + return $attributes;
176 + }
177 +
64 178 public static function exportForms($formIds)
65 179 {
66 180 $result = Form::with(['formMeta'])
67 181 ->whereIn('id', $formIds)
@@ -75,9 +189,9 @@
75 189 $form->metas = $formMetaFiltered;
76 190 $form->form_fields = json_decode($form->form_fields);
77 191 $forms[] = $form;
78 192 }
79 -
193 +
80 194 $fileName = 'fluentform-export-forms-' . count($forms) . '-' . date('d-m-Y') . '.json';
81 195
82 196 header('Content-disposition: attachment; filename=' . $fileName);
83 197
@@ -88,8 +202,34 @@
88 202 die();
89 203 }
90 204
91 205 /**
206 + * Build the notice shown when imported custom JS/CSS or executable date
207 + * configuration was skipped because the importer lacks unfiltered_html. Returns an empty string when nothing was skipped.
208 + *
209 + * @param int $skippedForms
210 + * @param int $totalForms
211 + * @return string
212 + */
213 + protected static function restrictedCodeNotice($skippedForms, $totalForms)
214 + {
215 + if (!$skippedForms) {
216 + return '';
217 + }
218 +
219 + if ($totalForms < 2) {
220 + return __('Custom JS, CSS and advanced date configuration were not imported because your account cannot add custom code. Ask an administrator to add it.', 'fluentform');
221 + }
222 +
223 + return sprintf(
224 + /* translators: 1: number of forms whose custom code was skipped, 2: total number of imported forms */
225 + __('Custom JS, CSS and advanced date configuration were not imported for %1$d of %2$d forms because your account cannot add custom code. Ask an administrator to add it.', 'fluentform'),
226 + $skippedForms,
227 + $totalForms
228 + );
229 + }
230 +
231 + /**
92 232 * @param File $file The uploaded JSON file
93 233 * @param bool $applyDefaultStyle Whether to apply default style settings to imported forms
94 234 * @throws Exception
95 235 */
@@ -97,8 +237,9 @@
97 237 {
98 238 if ($file instanceof File) {
99 239 $forms = \json_decode($file->getContents(), true);
100 240 $insertedForms = [];
241 + $restrictedCodeForms = 0;
101 242 if ($forms && is_array($forms)) {
102 243 foreach ($forms as $formItem) {
103 244 $formFields = json_encode([]);
104 245 if ($fields = Arr::get($formItem, 'form', '')) {
@@ -107,8 +248,28 @@
107 248 $formFields = json_encode($fields);
108 249 } else {
109 250 throw new Exception(esc_html__('You have a faulty JSON file, please export the Fluent Forms again.', 'fluentform'));
110 251 }
252 +
253 + // SECURITY (FINDING-07): the editor save path routes form_fields through
254 + // Updater::sanitizeFields (skipped only for unfiltered_html users), but import
255 + // stored them verbatim, so an importer without unfiltered_html could plant
256 + // stored XSS (e.g. a field label of <img onerror=...>). Apply the same
257 + // recursive HTML sanitizer used for imported meta values unless the importer
258 + // may author raw HTML.
259 + $droppedDateConfigs = 0;
260 + if (!fluentformCanUnfilteredHTML()) {
261 + $decodedFields = json_decode($formFields, true);
262 + if (is_array($decodedFields)) {
263 + self::sanitizeJsonNode($decodedFields);
264 + $decodedFields['fields'] = DateConfigPolicy::dropExecutableConfigs(
265 + Arr::get($decodedFields, 'fields', []),
266 + $droppedDateConfigs
267 + );
268 + $formFields = wp_json_encode($decodedFields) ?: $formFields;
269 + }
270 + }
271 +
111 272 $formTitle = sanitize_text_field(Arr::get($formItem, 'title'));
112 273 $form = [
113 274 'title' => $formTitle ?: 'Blank Form',
114 275 'form_fields' => $formFields,
@@ -131,13 +292,28 @@
131 292 'title' => $form['title'],
132 293 'edit_url' => admin_url('admin.php?page=fluent_forms&route=editor&form_id=' . $formId),
133 294 ];
134 295
296 + $skippedCustomCode = $droppedDateConfigs > 0;
297 +
135 298 if (isset($formItem['metas'])) {
136 299 foreach ($formItem['metas'] as $metaData) {
137 300 $metaKey = sanitize_text_field(Arr::get($metaData, 'meta_key'));
138 301 $metaValue = Arr::get($metaData, 'value');
139 - if ("ffc_form_settings_generated_css" == $metaKey || "ffc_form_settings_meta" == $metaKey) {
302 + // SECURITY (FINDING-08): Customizer::store() refuses to save custom
303 + // JS/CSS without unfiltered_html; import must honor the same boundary.
304 + // Sanitizing _custom_form_js via fluentform_kses_js is insufficient
305 + // because the value is JS *code* executed inside a <script> block (kses
306 + // only strips <script> tags), so skip these keys entirely for importers
307 + // who cannot author raw JS/CSS.
308 + if (
309 + in_array($metaKey, ['_custom_form_js', '_custom_form_css'], true)
310 + && !fluentformCanUnfilteredHTML()
311 + ) {
312 + $skippedCustomCode = true;
313 + continue;
314 + }
315 + if ('ffc_form_settings_generated_css' == $metaKey || 'ffc_form_settings_meta' == $metaKey) {
140 316 $metaValue = str_replace('ff_conv_app_' . Arr::get($formItem, 'id'), 'ff_conv_app_' . $formId, $metaValue);
141 317 }
142 318 $metaValue = static::sanitizeImportedMetaValue($metaKey, $metaValue);
143 319 $settings = [
@@ -160,8 +336,12 @@
160 336 }
161 337 }
162 338 }
163 339
340 + if ($skippedCustomCode) {
341 + $restrictedCodeForms++;
342 + }
343 +
164 344 do_action('fluentform/form_imported', $formId);
165 345
166 346 // Apply default style if requested
167 347 if ($applyDefaultStyle) {
@@ -169,10 +349,11 @@
169 349 }
170 350 }
171 351
172 352 return ([
173 - 'message' => __('You form has been successfully imported.', 'fluentform'),
174 - 'inserted_forms' => $insertedForms,
353 + 'message' => __('You form has been successfully imported.', 'fluentform'),
354 + 'inserted_forms' => $insertedForms,
355 + 'restricted_code_notice' => static::restrictedCodeNotice($restrictedCodeForms, count($insertedForms)),
175 356 ]);
176 357 }
177 358 }
178 359 throw new Exception(esc_html__('You have a faulty JSON file, please export the Fluent Forms again.', 'fluentform'));
@@ -196,8 +377,9 @@
196 377 $type = sanitize_key(Arr::get($args, 'format', 'csv'));
197 378 if (!in_array($type, ['csv', 'ods', 'xlsx', 'json'])) {
198 379 exit('Invalid requested format');
199 380 }
381 + self::markDownloadStarted(Arr::get($args, 'download_token'));
200 382 if ('json' == $type) {
201 383 self::exportAsJSON($form, $args);
202 384 }
203 385 if (!defined('FLUENTFORM_DOING_CSV_EXPORT')) {
@@ -204,16 +386,16 @@
204 386 define('FLUENTFORM_DOING_CSV_EXPORT', true);
205 387 }
206 388 $formInputs = FormFieldsParser::getEntryInputs($form, ['admin_label', 'raw']);
207 389 $inputLabels = FormFieldsParser::getAdminLabels($form, $formInputs);
208 - $selectedLabels = Arr::get($args,'fields_to_export');
390 + $selectedLabels = Arr::get($args, 'fields_to_export');
209 391 if (is_string($selectedLabels) && Helper::isJson($selectedLabels)) {
210 392 $selectedLabels = \json_decode($selectedLabels, true);
211 393 }
212 394 $selectedLabels = fluentFormSanitizer($selectedLabels);
213 -
395 +
214 396 $withNotes = isset($args['with_notes']);
215 -
397 +
216 398 //filter out unselected fields
217 399 if (!empty($selectedLabels)) {
218 400 foreach ($inputLabels as $key => $value) {
219 401 if (!in_array($key, $selectedLabels) && isset($inputLabels[$key])) {
@@ -220,9 +402,9 @@
220 402 unset($inputLabels[$key]);
221 403 }
222 404 }
223 405 }
224 -
406 +
225 407 $submissions = self::getSubmissions($args);
226 408 $submissions = FormDataParser::parseFormEntries($submissions, $form, $formInputs);
227 409 $parsedShortCodes = [];
228 410 $exportData = [];
@@ -231,9 +413,11 @@
231 413
232 414 // Preload notes for all submissions in a single query to avoid N+1
233 415 $notesMap = [];
234 416 if ($withNotes && count($submissions)) {
235 - $submissionIds = array_map(function ($s) { return is_object($s) ? $s->id : $s['id']; }, $submissions->toArray());
417 + $submissionIds = array_map(function ($s) {
418 + return is_object($s) ? $s->id : $s['id'];
419 + }, $submissions->toArray());
236 420 $allNotes = SubmissionMeta::whereIn('response_id', $submissionIds)
237 421 ->where('meta_key', '_notes')
238 422 ->get();
239 423 foreach ($allNotes as $note) {
@@ -243,20 +427,20 @@
243 427
244 428 foreach ($submissions as $submission) {
245 429
246 430 $submission->response = json_decode($submission->response, true);
247 -
431 +
248 432 $temp = [];
249 433 foreach ($inputLabels as $field => $label) {
250 -
434 +
251 435 //format tabular grid data for CSV/XLSV/ODS export
252 - if (isset($formInputs[$field]['element']) && "tabular_grid" === $formInputs[$field]['element']) {
436 + if (isset($formInputs[$field]['element']) && 'tabular_grid' === $formInputs[$field]['element']) {
253 437 $gridRawData = Arr::get($submission->response, $field);
254 438 $content = Helper::getTabularGridFormatValue($gridRawData, Arr::get($formInputs, $field), ' | ');
255 - } elseif (isset($formInputs[$field]['element']) && "subscription_payment_component" === $formInputs[$field]['element']) {
439 + } elseif (isset($formInputs[$field]['element']) && 'subscription_payment_component' === $formInputs[$field]['element']) {
256 440 //resolve plane name for subscription field
257 441 $planIndex = Arr::get($submission->user_inputs, $field);
258 - $planLabel = Arr::get($formInputs, "{$field}.raw.settings.subscription_options.{$planIndex}.name");
442 + $planLabel = Arr::get($formInputs, "{$field}.raw.settings.subscription_options.{$planIndex}.name");
259 443 if ($planLabel) {
260 444 $content = $planLabel;
261 445 } else {
262 446 $content = self::getFieldExportContent($submission, $field);
@@ -262,15 +446,15 @@
262 446 $content = self::getFieldExportContent($submission, $field);
263 447 }
264 448 } else {
265 449 $content = self::getFieldExportContent($submission, $field);
266 - if (Arr::get($formInputs, $field . '.element') === "input_number" && is_numeric($content)) {
450 + if (Arr::get($formInputs, $field . '.element') === 'input_number' && is_numeric($content)) {
267 451 $content = $content + 0;
268 452 }
269 453 }
270 454 $temp[] = Helper::sanitizeForCSV($content);
271 455 }
272 -
456 +
273 457 if (!empty($selectedShortcodes)) {
274 458 $regularShortcodes = self::getRegularExportShortcodes($selectedShortcodes, $legacyShortcodeHeaders);
275 459
276 460 if (!empty($regularShortcodes)) {
@@ -283,22 +467,27 @@
283 467 true
284 468 );
285 469 }
286 470
287 - $temp = array_merge(
288 - $temp,
289 - self::getSelectedShortcodeExportValues(
290 - $selectedShortcodes,
291 - $parsedShortCodes,
292 - $legacyShortcodeHeaders,
293 - $submission
294 - )
471 + // SECURITY (FINDING-17): shortcode-export values (which include submitter-controlled
472 + // {inputs.*} content) bypassed the CSV formula guard applied to regular columns.
473 + // Sanitize each so a leading = - + @ etc. cannot execute when opened in a spreadsheet.
474 + $shortcodeValues = self::getSelectedShortcodeExportValues(
475 + $selectedShortcodes,
476 + $parsedShortCodes,
477 + $legacyShortcodeHeaders,
478 + $submission
295 479 );
480 + $shortcodeValues = array_map(function ($v) {
481 + return is_scalar($v) ? Helper::sanitizeForCSV((string) $v) : $v;
482 + }, $shortcodeValues);
483 + $temp = array_merge($temp, $shortcodeValues);
296 484 }
297 485 if ($withNotes) {
298 486 $noteValues = isset($notesMap[$submission->id]) ? $notesMap[$submission->id] : [];
299 487 if (!empty($noteValues)) {
300 - $temp[] = implode(", ", $noteValues);
488 + // SECURITY (FINDING-17): notes are submitter-influenceable and were exported raw.
489 + $temp[] = Helper::sanitizeForCSV(implode(", ", $noteValues));
301 490 }
302 491 }
303 492
304 493 $temp = apply_filters('fluentform/export_entry_metadata', $temp, $submission, $form, $args);
@@ -312,17 +501,22 @@
312 501 $selectedShortcodes,
313 502 $parsedShortCodes,
314 503 $legacyShortcodeHeaders
315 504 );
316 -
505 +
317 506 $inputLabels = array_merge($inputLabels, $extraLabels);
318 - if($withNotes){
319 - $inputLabels[] = __('Notes','fluentform');
507 + if ($withNotes) {
508 + $inputLabels[] = __('Notes', 'fluentform');
320 509 }
321 510 $inputLabels = apply_filters('fluentform/export_entry_metadata_labels', $inputLabels, $form, $args);
322 511
323 - $data = array_merge([array_values($inputLabels)], $exportData);
324 -
512 + // SECURITY (FINDING-17): sanitize the header row too — field/shortcode labels can start with
513 + // a formula lead character (=, +, -, @) and were exported unguarded.
514 + $headerRow = array_map(function ($v) {
515 + return is_scalar($v) ? Helper::sanitizeForCSV((string) $v) : $v;
516 + }, array_values($inputLabels));
517 + $data = array_merge([$headerRow], $exportData);
518 +
325 519 $data = apply_filters('fluentform/export_data', $data, $form, $exportData, $inputLabels);
326 520 $fileName = self::getReadableExportFileName($form->title);
327 521 self::downloadOfficeDoc($data, $type, $fileName);
328 522 }
@@ -500,8 +694,30 @@
500 694
501 695 return $sanitizedTitle . '-' . date('Y-m-d');
502 696 }
503 697
698 + /**
699 + * Set a short-lived cookie the admin page polls to know the download has started.
700 + *
701 + * @param string|null $token
702 + * @return void
703 + */
704 + private static function markDownloadStarted($token)
705 + {
706 + $token = substr(sanitize_key((string) $token), 0, 32);
707 +
708 + if (!$token || headers_sent()) {
709 + return;
710 + }
711 +
712 + setcookie('ff_export_' . $token, '1', [
713 + 'expires' => time() + 60,
714 + 'path' => '/',
715 + 'secure' => is_ssl(),
716 + 'samesite' => 'Lax',
717 + ]);
718 + }
719 +
504 720 private static function sendDownloadHeaders($contentType, $fileName)
505 721 {
506 722 $safeFileName = basename((string) $fileName);
507 723 $encodedFileName = rawurlencode($safeFileName);
@@ -524,9 +740,9 @@
524 740 'fluentform_draft_submissions',
525 741 ];
526 742 if (!in_array($tableName, $allowedTables, true)) {
527 743 wp_send_json([
528 - 'message' => __('Invalid table name for export.', 'fluentform')
744 + 'message' => __('Invalid table name for export.', 'fluentform'),
529 745 ], 422);
530 746 }
531 747 $query = wpFluent()->table($tableName)
532 748 ->where('form_id', (int) Arr::get($args, 'form_id'))
@@ -541,9 +757,9 @@
541 757 ->orWhere('response', 'LIKE', "%{$escaped}%");
542 758 });
543 759 }
544 760 } else {
545 - $query = (new Submission)->customQuery($args);
761 + $query = (new Submission())->customQuery($args);
546 762 }
547 763
548 764 $entries = fluentFormSanitizer(Arr::get($args, 'entries', []));
549 765 $query->when(is_array($entries) && (count($entries) > 0), function ($q) use ($entries) {
@@ -561,11 +777,14 @@
561 777 {
562 778 $data = array_map(function ($item) {
563 779 return array_map(function ($itemValue) {
564 780 if (is_array($itemValue)) {
565 - return implode(', ', $itemValue);
781 + $itemValue = implode(', ', $itemValue);
566 782 }
567 - return $itemValue;
783 +
784 + return is_string($itemValue)
785 + ? Helper::sanitizeForCSV($itemValue)
786 + : $itemValue;
568 787 }, $item);
569 788 }, $data);
570 789 // Load Composer autoloader for OpenSpout
571 790 require_once FLUENTFORM_DIR_PATH . '/vendor/autoload.php';
@@ -617,6 +836,5 @@
617 836
618 837 return \OpenSpout\Writer\Common\Creator\WriterEntityFactory::createRow($cells);
619 838 }, $data);
620 839 }
621 -
622 840 }