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/Services/Transfer/TransferService.php +214 -36 6.2.56.2.14 View file →
@@ -13,8 +13,9 @@
13 13 use FluentForm\App\Modules\Acl\Acl;
14 14 use FluentForm\App\Modules\Form\FormDataParser;
15 15 use FluentForm\App\Modules\Form\FormFieldsParser;
16 16 use FluentForm\App\Services\FormBuilder\ShortCodeParser;
17 +use FluentForm\App\Services\FormBuilder\DateConfigPolicy;
17 18 use FluentForm\Framework\Foundation\App;
18 19 use FluentForm\Framework\Http\Request\File;
19 20 use FluentForm\Framework\Support\Arr;
20 21
@@ -46,14 +47,22 @@
46 47
47 48 private static function sanitizeJsonNode(&$node)
48 49 {
49 50 if (is_array($node)) {
50 - foreach ($node as &$value) {
51 + foreach ($node as $key => &$value) {
52 + if ('attributes' === $key) {
53 + $value = self::dropEventHandlerAttributeKeys($value);
54 + }
55 + $value = self::sanitizeAttributeControlSetting($key, $value);
51 56 self::sanitizeJsonNode($value);
52 57 }
53 58 unset($value);
54 59 } elseif (is_object($node)) {
55 60 foreach (get_object_vars($node) as $key => $value) {
61 + if ('attributes' === $key) {
62 + $value = self::dropEventHandlerAttributeKeys($value);
63 + }
64 + $value = self::sanitizeAttributeControlSetting($key, $value);
56 65 self::sanitizeJsonNode($value);
57 66 $node->{$key} = $value;
58 67 }
59 68 } elseif (is_string($node)) {
@@ -60,8 +69,96 @@
60 69 $node = wp_kses_post($node);
61 70 }
62 71 }
63 72
73 + private static function sanitizeAttributeControlSetting($key, $value)
74 + {
75 + if ('max_repeat_field' === $key) {
76 + return is_scalar($value) && '' !== trim((string) $value) ? absint($value) : '';
77 + }
78 +
79 + if ('display_mode' === $key) {
80 + $mode = is_scalar($value) ? sanitize_key((string) $value) : '';
81 + return in_array($mode, ['accordion', 'tabs'], true) ? $mode : 'accordion';
82 + }
83 +
84 + if ('display_type' === $key) {
85 + return is_scalar($value) ? sanitize_html_class((string) $value) : '';
86 + }
87 +
88 + if ('subscription_options' === $key && is_array($value)) {
89 + foreach ($value as &$option) {
90 + if (!is_array($option)) {
91 + continue;
92 + }
93 +
94 + foreach (['name', 'user_input_label'] as $labelKey) {
95 + if (!array_key_exists($labelKey, $option)) {
96 + continue;
97 + }
98 +
99 + $label = $option[$labelKey];
100 + $option[$labelKey] = is_scalar($label) ? fluentform_sanitize_html((string) $label) : '';
101 + }
102 + }
103 + unset($option);
104 +
105 + return $value;
106 + }
107 +
108 + if ('pricing_options' !== $key || !is_array($value)) {
109 + return $value;
110 + }
111 +
112 + foreach ($value as &$option) {
113 + if (!is_array($option)) {
114 + continue;
115 + }
116 +
117 + if (array_key_exists('label', $option)) {
118 + $label = $option['label'];
119 + $option['label'] = is_scalar($label) ? fluentform_sanitize_html((string) $label) : '';
120 + }
121 +
122 + if (array_key_exists('image', $option)) {
123 + $image = $option['image'];
124 + $option['image'] = is_scalar($image) ? esc_url_raw((string) $image) : '';
125 + }
126 + }
127 + unset($option);
128 +
129 + return $value;
130 + }
131 +
132 + /**
133 + * kses cleans string values only, so an `onfocus` KEY survives an import untouched.
134 + * Shares the Helper rule so the two write paths cannot drift apart.
135 + */
136 + private static function dropEventHandlerAttributeKeys($attributes)
137 + {
138 + if (!is_array($attributes) && !is_object($attributes)) {
139 + return $attributes;
140 + }
141 +
142 + $keys = is_object($attributes)
143 + ? array_keys(get_object_vars($attributes))
144 + : array_keys($attributes);
145 +
146 + foreach ($keys as $key) {
147 + if (Helper::isSafeAttributeKey($key)) {
148 + continue;
149 + }
150 +
151 + if (is_object($attributes)) {
152 + unset($attributes->{$key});
153 + } else {
154 + unset($attributes[$key]);
155 + }
156 + }
157 +
158 + return $attributes;
159 + }
160 +
64 161 public static function exportForms($formIds)
65 162 {
66 163 $result = Form::with(['formMeta'])
67 164 ->whereIn('id', $formIds)
@@ -75,9 +172,9 @@
75 172 $form->metas = $formMetaFiltered;
76 173 $form->form_fields = json_decode($form->form_fields);
77 174 $forms[] = $form;
78 175 }
79 -
176 +
80 177 $fileName = 'fluentform-export-forms-' . count($forms) . '-' . date('d-m-Y') . '.json';
81 178
82 179 header('Content-disposition: attachment; filename=' . $fileName);
83 180
@@ -88,8 +185,34 @@
88 185 die();
89 186 }
90 187
91 188 /**
189 + * Build the notice shown when imported custom JS/CSS or executable date
190 + * configuration was skipped because the importer lacks unfiltered_html. Returns an empty string when nothing was skipped.
191 + *
192 + * @param int $skippedForms
193 + * @param int $totalForms
194 + * @return string
195 + */
196 + protected static function restrictedCodeNotice($skippedForms, $totalForms)
197 + {
198 + if (!$skippedForms) {
199 + return '';
200 + }
201 +
202 + if ($totalForms < 2) {
203 + 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');
204 + }
205 +
206 + return sprintf(
207 + /* translators: 1: number of forms whose custom code was skipped, 2: total number of imported forms */
208 + __('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'),
209 + $skippedForms,
210 + $totalForms
211 + );
212 + }
213 +
214 + /**
92 215 * @param File $file The uploaded JSON file
93 216 * @param bool $applyDefaultStyle Whether to apply default style settings to imported forms
94 217 * @throws Exception
95 218 */
@@ -97,8 +220,9 @@
97 220 {
98 221 if ($file instanceof File) {
99 222 $forms = \json_decode($file->getContents(), true);
100 223 $insertedForms = [];
224 + $restrictedCodeForms = 0;
101 225 if ($forms && is_array($forms)) {
102 226 foreach ($forms as $formItem) {
103 227 $formFields = json_encode([]);
104 228 if ($fields = Arr::get($formItem, 'form', '')) {
@@ -107,8 +231,28 @@
107 231 $formFields = json_encode($fields);
108 232 } else {
109 233 throw new Exception(esc_html__('You have a faulty JSON file, please export the Fluent Forms again.', 'fluentform'));
110 234 }
235 +
236 + // SECURITY (FINDING-07): the editor save path routes form_fields through
237 + // Updater::sanitizeFields (skipped only for unfiltered_html users), but import
238 + // stored them verbatim, so an importer without unfiltered_html could plant
239 + // stored XSS (e.g. a field label of <img onerror=...>). Apply the same
240 + // recursive HTML sanitizer used for imported meta values unless the importer
241 + // may author raw HTML.
242 + $droppedDateConfigs = 0;
243 + if (!fluentformCanUnfilteredHTML()) {
244 + $decodedFields = json_decode($formFields, true);
245 + if (is_array($decodedFields)) {
246 + self::sanitizeJsonNode($decodedFields);
247 + $decodedFields['fields'] = DateConfigPolicy::dropExecutableConfigs(
248 + Arr::get($decodedFields, 'fields', []),
249 + $droppedDateConfigs
250 + );
251 + $formFields = wp_json_encode($decodedFields) ?: $formFields;
252 + }
253 + }
254 +
111 255 $formTitle = sanitize_text_field(Arr::get($formItem, 'title'));
112 256 $form = [
113 257 'title' => $formTitle ?: 'Blank Form',
114 258 'form_fields' => $formFields,
@@ -131,13 +275,28 @@
131 275 'title' => $form['title'],
132 276 'edit_url' => admin_url('admin.php?page=fluent_forms&route=editor&form_id=' . $formId),
133 277 ];
134 278
279 + $skippedCustomCode = $droppedDateConfigs > 0;
280 +
135 281 if (isset($formItem['metas'])) {
136 282 foreach ($formItem['metas'] as $metaData) {
137 283 $metaKey = sanitize_text_field(Arr::get($metaData, 'meta_key'));
138 284 $metaValue = Arr::get($metaData, 'value');
139 - if ("ffc_form_settings_generated_css" == $metaKey || "ffc_form_settings_meta" == $metaKey) {
285 + // SECURITY (FINDING-08): Customizer::store() refuses to save custom
286 + // JS/CSS without unfiltered_html; import must honor the same boundary.
287 + // Sanitizing _custom_form_js via fluentform_kses_js is insufficient
288 + // because the value is JS *code* executed inside a <script> block (kses
289 + // only strips <script> tags), so skip these keys entirely for importers
290 + // who cannot author raw JS/CSS.
291 + if (
292 + in_array($metaKey, ['_custom_form_js', '_custom_form_css'], true)
293 + && !fluentformCanUnfilteredHTML()
294 + ) {
295 + $skippedCustomCode = true;
296 + continue;
297 + }
298 + if ('ffc_form_settings_generated_css' == $metaKey || 'ffc_form_settings_meta' == $metaKey) {
140 299 $metaValue = str_replace('ff_conv_app_' . Arr::get($formItem, 'id'), 'ff_conv_app_' . $formId, $metaValue);
141 300 }
142 301 $metaValue = static::sanitizeImportedMetaValue($metaKey, $metaValue);
143 302 $settings = [
@@ -160,8 +319,12 @@
160 319 }
161 320 }
162 321 }
163 322
323 + if ($skippedCustomCode) {
324 + $restrictedCodeForms++;
325 + }
326 +
164 327 do_action('fluentform/form_imported', $formId);
165 328
166 329 // Apply default style if requested
167 330 if ($applyDefaultStyle) {
@@ -169,10 +332,11 @@
169 332 }
170 333 }
171 334
172 335 return ([
173 - 'message' => __('You form has been successfully imported.', 'fluentform'),
174 - 'inserted_forms' => $insertedForms,
336 + 'message' => __('You form has been successfully imported.', 'fluentform'),
337 + 'inserted_forms' => $insertedForms,
338 + 'restricted_code_notice' => static::restrictedCodeNotice($restrictedCodeForms, count($insertedForms)),
175 339 ]);
176 340 }
177 341 }
178 342 throw new Exception(esc_html__('You have a faulty JSON file, please export the Fluent Forms again.', 'fluentform'));
@@ -204,16 +368,16 @@
204 368 define('FLUENTFORM_DOING_CSV_EXPORT', true);
205 369 }
206 370 $formInputs = FormFieldsParser::getEntryInputs($form, ['admin_label', 'raw']);
207 371 $inputLabels = FormFieldsParser::getAdminLabels($form, $formInputs);
208 - $selectedLabels = Arr::get($args,'fields_to_export');
372 + $selectedLabels = Arr::get($args, 'fields_to_export');
209 373 if (is_string($selectedLabels) && Helper::isJson($selectedLabels)) {
210 374 $selectedLabels = \json_decode($selectedLabels, true);
211 375 }
212 376 $selectedLabels = fluentFormSanitizer($selectedLabels);
213 -
377 +
214 378 $withNotes = isset($args['with_notes']);
215 -
379 +
216 380 //filter out unselected fields
217 381 if (!empty($selectedLabels)) {
218 382 foreach ($inputLabels as $key => $value) {
219 383 if (!in_array($key, $selectedLabels) && isset($inputLabels[$key])) {
@@ -220,9 +384,9 @@
220 384 unset($inputLabels[$key]);
221 385 }
222 386 }
223 387 }
224 -
388 +
225 389 $submissions = self::getSubmissions($args);
226 390 $submissions = FormDataParser::parseFormEntries($submissions, $form, $formInputs);
227 391 $parsedShortCodes = [];
228 392 $exportData = [];
@@ -231,9 +395,11 @@
231 395
232 396 // Preload notes for all submissions in a single query to avoid N+1
233 397 $notesMap = [];
234 398 if ($withNotes && count($submissions)) {
235 - $submissionIds = array_map(function ($s) { return is_object($s) ? $s->id : $s['id']; }, $submissions->toArray());
399 + $submissionIds = array_map(function ($s) {
400 + return is_object($s) ? $s->id : $s['id'];
401 + }, $submissions->toArray());
236 402 $allNotes = SubmissionMeta::whereIn('response_id', $submissionIds)
237 403 ->where('meta_key', '_notes')
238 404 ->get();
239 405 foreach ($allNotes as $note) {
@@ -243,20 +409,20 @@
243 409
244 410 foreach ($submissions as $submission) {
245 411
246 412 $submission->response = json_decode($submission->response, true);
247 -
413 +
248 414 $temp = [];
249 415 foreach ($inputLabels as $field => $label) {
250 -
416 +
251 417 //format tabular grid data for CSV/XLSV/ODS export
252 - if (isset($formInputs[$field]['element']) && "tabular_grid" === $formInputs[$field]['element']) {
418 + if (isset($formInputs[$field]['element']) && 'tabular_grid' === $formInputs[$field]['element']) {
253 419 $gridRawData = Arr::get($submission->response, $field);
254 420 $content = Helper::getTabularGridFormatValue($gridRawData, Arr::get($formInputs, $field), ' | ');
255 - } elseif (isset($formInputs[$field]['element']) && "subscription_payment_component" === $formInputs[$field]['element']) {
421 + } elseif (isset($formInputs[$field]['element']) && 'subscription_payment_component' === $formInputs[$field]['element']) {
256 422 //resolve plane name for subscription field
257 423 $planIndex = Arr::get($submission->user_inputs, $field);
258 - $planLabel = Arr::get($formInputs, "{$field}.raw.settings.subscription_options.{$planIndex}.name");
424 + $planLabel = Arr::get($formInputs, "{$field}.raw.settings.subscription_options.{$planIndex}.name");
259 425 if ($planLabel) {
260 426 $content = $planLabel;
261 427 } else {
262 428 $content = self::getFieldExportContent($submission, $field);
@@ -262,15 +428,15 @@
262 428 $content = self::getFieldExportContent($submission, $field);
263 429 }
264 430 } else {
265 431 $content = self::getFieldExportContent($submission, $field);
266 - if (Arr::get($formInputs, $field . '.element') === "input_number" && is_numeric($content)) {
432 + if (Arr::get($formInputs, $field . '.element') === 'input_number' && is_numeric($content)) {
267 433 $content = $content + 0;
268 434 }
269 435 }
270 436 $temp[] = Helper::sanitizeForCSV($content);
271 437 }
272 -
438 +
273 439 if (!empty($selectedShortcodes)) {
274 440 $regularShortcodes = self::getRegularExportShortcodes($selectedShortcodes, $legacyShortcodeHeaders);
275 441
276 442 if (!empty($regularShortcodes)) {
@@ -283,22 +449,27 @@
283 449 true
284 450 );
285 451 }
286 452
287 - $temp = array_merge(
288 - $temp,
289 - self::getSelectedShortcodeExportValues(
290 - $selectedShortcodes,
291 - $parsedShortCodes,
292 - $legacyShortcodeHeaders,
293 - $submission
294 - )
453 + // SECURITY (FINDING-17): shortcode-export values (which include submitter-controlled
454 + // {inputs.*} content) bypassed the CSV formula guard applied to regular columns.
455 + // Sanitize each so a leading = - + @ etc. cannot execute when opened in a spreadsheet.
456 + $shortcodeValues = self::getSelectedShortcodeExportValues(
457 + $selectedShortcodes,
458 + $parsedShortCodes,
459 + $legacyShortcodeHeaders,
460 + $submission
295 461 );
462 + $shortcodeValues = array_map(function ($v) {
463 + return is_scalar($v) ? Helper::sanitizeForCSV((string) $v) : $v;
464 + }, $shortcodeValues);
465 + $temp = array_merge($temp, $shortcodeValues);
296 466 }
297 467 if ($withNotes) {
298 468 $noteValues = isset($notesMap[$submission->id]) ? $notesMap[$submission->id] : [];
299 469 if (!empty($noteValues)) {
300 - $temp[] = implode(", ", $noteValues);
470 + // SECURITY (FINDING-17): notes are submitter-influenceable and were exported raw.
471 + $temp[] = Helper::sanitizeForCSV(implode(", ", $noteValues));
301 472 }
302 473 }
303 474
304 475 $temp = apply_filters('fluentform/export_entry_metadata', $temp, $submission, $form, $args);
@@ -312,17 +483,22 @@
312 483 $selectedShortcodes,
313 484 $parsedShortCodes,
314 485 $legacyShortcodeHeaders
315 486 );
316 -
487 +
317 488 $inputLabels = array_merge($inputLabels, $extraLabels);
318 - if($withNotes){
319 - $inputLabels[] = __('Notes','fluentform');
489 + if ($withNotes) {
490 + $inputLabels[] = __('Notes', 'fluentform');
320 491 }
321 492 $inputLabels = apply_filters('fluentform/export_entry_metadata_labels', $inputLabels, $form, $args);
322 493
323 - $data = array_merge([array_values($inputLabels)], $exportData);
324 -
494 + // SECURITY (FINDING-17): sanitize the header row too — field/shortcode labels can start with
495 + // a formula lead character (=, +, -, @) and were exported unguarded.
496 + $headerRow = array_map(function ($v) {
497 + return is_scalar($v) ? Helper::sanitizeForCSV((string) $v) : $v;
498 + }, array_values($inputLabels));
499 + $data = array_merge([$headerRow], $exportData);
500 +
325 501 $data = apply_filters('fluentform/export_data', $data, $form, $exportData, $inputLabels);
326 502 $fileName = self::getReadableExportFileName($form->title);
327 503 self::downloadOfficeDoc($data, $type, $fileName);
328 504 }
@@ -524,9 +700,9 @@
524 700 'fluentform_draft_submissions',
525 701 ];
526 702 if (!in_array($tableName, $allowedTables, true)) {
527 703 wp_send_json([
528 - 'message' => __('Invalid table name for export.', 'fluentform')
704 + 'message' => __('Invalid table name for export.', 'fluentform'),
529 705 ], 422);
530 706 }
531 707 $query = wpFluent()->table($tableName)
532 708 ->where('form_id', (int) Arr::get($args, 'form_id'))
@@ -541,9 +717,9 @@
541 717 ->orWhere('response', 'LIKE', "%{$escaped}%");
542 718 });
543 719 }
544 720 } else {
545 - $query = (new Submission)->customQuery($args);
721 + $query = (new Submission())->customQuery($args);
546 722 }
547 723
548 724 $entries = fluentFormSanitizer(Arr::get($args, 'entries', []));
549 725 $query->when(is_array($entries) && (count($entries) > 0), function ($q) use ($entries) {
@@ -561,11 +737,14 @@
561 737 {
562 738 $data = array_map(function ($item) {
563 739 return array_map(function ($itemValue) {
564 740 if (is_array($itemValue)) {
565 - return implode(', ', $itemValue);
741 + $itemValue = implode(', ', $itemValue);
566 742 }
567 - return $itemValue;
743 +
744 + return is_string($itemValue)
745 + ? Helper::sanitizeForCSV($itemValue)
746 + : $itemValue;
568 747 }, $item);
569 748 }, $data);
570 749 // Load Composer autoloader for OpenSpout
571 750 require_once FLUENTFORM_DIR_PATH . '/vendor/autoload.php';
@@ -617,6 +796,5 @@
617 796
618 797 return \OpenSpout\Writer\Common\Creator\WriterEntityFactory::createRow($cells);
619 798 }, $data);
620 799 }
621 -
622 800 }