| @@ -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) |
| @@ -88,10 +202,10 @@ | ||
| 88 | 202 | die(); |
| 89 | 203 | } |
| 90 | 204 | |
| 91 | 205 | /** |
| 92 | - * Build the notice shown when imported custom JS/CSS was skipped because the | |
| 93 | - * importer lacks unfiltered_html. Returns an empty string when nothing was skipped. | |
| 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. | |
| 94 | 208 | * |
| 95 | 209 | * @param int $skippedForms |
| 96 | 210 | * @param int $totalForms |
| 97 | 211 | * @return string |
| @@ -102,14 +216,14 @@ | ||
| 102 | 216 | return ''; |
| 103 | 217 | } |
| 104 | 218 | |
| 105 | 219 | if ($totalForms < 2) { |
| 106 | - return __('Custom JS and CSS were not imported because your account cannot add custom code. Ask an administrator to add it.', 'fluentform'); | |
| 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'); | |
| 107 | 221 | } |
| 108 | 222 | |
| 109 | 223 | return sprintf( |
| 110 | 224 | /* translators: 1: number of forms whose custom code was skipped, 2: total number of imported forms */ |
| 111 | - __('Custom JS and CSS were not imported for %1$d of %2$d forms because your account cannot add custom code. Ask an administrator to add it.', 'fluentform'), | |
| 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'), | |
| 112 | 226 | $skippedForms, |
| 113 | 227 | $totalForms |
| 114 | 228 | ); |
| 115 | 229 | } |
| @@ -141,12 +255,17 @@ | ||
| 141 | 255 | // stored them verbatim, so an importer without unfiltered_html could plant |
| 142 | 256 | // stored XSS (e.g. a field label of <img onerror=...>). Apply the same |
| 143 | 257 | // recursive HTML sanitizer used for imported meta values unless the importer |
| 144 | 258 | // may author raw HTML. |
| 259 | + $droppedDateConfigs = 0; | |
| 145 | 260 | if (!fluentformCanUnfilteredHTML()) { |
| 146 | 261 | $decodedFields = json_decode($formFields, true); |
| 147 | 262 | if (is_array($decodedFields)) { |
| 148 | - static::sanitizeJsonNode($decodedFields); | |
| 263 | + self::sanitizeJsonNode($decodedFields); | |
| 264 | + $decodedFields['fields'] = DateConfigPolicy::dropExecutableConfigs( | |
| 265 | + Arr::get($decodedFields, 'fields', []), | |
| 266 | + $droppedDateConfigs | |
| 267 | + ); | |
| 149 | 268 | $formFields = wp_json_encode($decodedFields) ?: $formFields; |
| 150 | 269 | } |
| 151 | 270 | } |
| 152 | 271 | |
| @@ -173,9 +292,9 @@ | ||
| 173 | 292 | 'title' => $form['title'], |
| 174 | 293 | 'edit_url' => admin_url('admin.php?page=fluent_forms&route=editor&form_id=' . $formId), |
| 175 | 294 | ]; |
| 176 | 295 | |
| 177 | - $skippedCustomCode = false; | |
| 296 | + $skippedCustomCode = $droppedDateConfigs > 0; | |
| 178 | 297 | |
| 179 | 298 | if (isset($formItem['metas'])) { |
| 180 | 299 | foreach ($formItem['metas'] as $metaData) { |
| 181 | 300 | $metaKey = sanitize_text_field(Arr::get($metaData, 'meta_key')); |
| @@ -258,8 +377,9 @@ | ||
| 258 | 377 | $type = sanitize_key(Arr::get($args, 'format', 'csv')); |
| 259 | 378 | if (!in_array($type, ['csv', 'ods', 'xlsx', 'json'])) { |
| 260 | 379 | exit('Invalid requested format'); |
| 261 | 380 | } |
| 381 | + self::markDownloadStarted(Arr::get($args, 'download_token')); | |
| 262 | 382 | if ('json' == $type) { |
| 263 | 383 | self::exportAsJSON($form, $args); |
| 264 | 384 | } |
| 265 | 385 | if (!defined('FLUENTFORM_DOING_CSV_EXPORT')) { |
| @@ -572,8 +692,30 @@ | ||
| 572 | 692 | $sanitizedTitle = 'export'; |
| 573 | 693 | } |
| 574 | 694 | |
| 575 | 695 | return $sanitizedTitle . '-' . date('Y-m-d'); |
| 696 | + } | |
| 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 | + ]); | |
| 576 | 718 | } |
| 577 | 719 | |
| 578 | 720 | private static function sendDownloadHeaders($contentType, $fileName) |
| 579 | 721 | { |