PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / trunk
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar vtrunk
3.3.1 3.3.0 3.2.14 3.2.13 3.2.12 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 trunk 0.2.5.5 0.2.5.6 0.2.5.7 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 1.2.1 All 156 releases
← All changes | includes/Admin/ImportExport.php +177 -23 3.2.11trunk View file →
@@ -12,8 +12,26 @@
12 12 */
13 13 class ImportExport{
14 14 use GetInstance;
15 15
16 + /**
17 + * Elementor meta keys that may cross the import/export boundary.
18 + *
19 + * Everything else is dropped. On import the incoming array used to be
20 + * looped verbatim into `add_post_meta()`, which let a caller write any meta
21 + * key it liked onto a post it had just created; on export every meta row of
22 + * the linked post was returned, which leaked whatever other plugins store
23 + * there.
24 + */
25 + const ELEMENTOR_META_ALLOWLIST = [
26 + '_elementor_data',
27 + '_elementor_edit_mode',
28 + '_elementor_template_type',
29 + '_elementor_page_settings',
30 + '_elementor_version',
31 + '_wp_page_template',
32 + ];
33 +
16 34 public function __construct(){
17 35 add_filter('nx_settings_tab_miscellaneous', [$this, 'settings_tab_help']);
18 36 add_filter('upload_mimes', [$this, 'cc_mime_types']);
19 37 add_filter('nx_settings', [$this, 'save_settings']);
@@ -156,8 +174,10 @@
156 174 return $tabs;
157 175 }
158 176
159 177 public function import($request){
178 + // Importing/exporting many notifications can exceed the default limit.
179 + // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged
160 180 @set_time_limit(0);
161 181 $params = $request->get_params();
162 182 $status = 'error';
163 183 if(!empty($params['import'])){
@@ -164,9 +184,24 @@
164 184 try {
165 185 $data = json_decode($params['import'], true);
166 186
167 187 if(!empty($data['settings'])){
168 - Settings::get_instance()->set('settings', $data['settings']);
188 + /*
189 + * This route resolves `edit_notificationx`, but replacing the
190 + * settings blob is settings authority. Writing through
191 + * `set()` also skipped the capability check, the `nx_settings`
192 + * filter and `preserve_protected_settings()` that the real
193 + * save path applies -- so import was a way around every guard
194 + * on `/settings`. Go through `save_settings()` instead.
195 + */
196 + if ( ! current_user_can( 'edit_notificationx_settings' ) ) {
197 + return new \WP_Error(
198 + 'nx_forbidden_settings_import',
199 + __( 'You are not allowed to import NotificationX settings.', 'notificationx' ),
200 + [ 'status' => 403 ]
201 + );
202 + }
203 + Settings::get_instance()->save_settings( $data['settings'] );
169 204 $status = 'success';
170 205 }
171 206
172 207 if(!empty($data['notifications'])){
@@ -178,25 +213,18 @@
178 213 $nx_id = $post['nx_id'];
179 214 unset($post['nx_id']);
180 215 unset($post['id']);
181 216
182 - if($post['source'] == 'press_bar' && !empty($post['elementor_id'])){
183 - $elementor_data = $data['elementor'][$post['elementor_id']];
184 - unset($elementor_data['post']['ID']);
185 -
186 - $el_id = wp_insert_post($elementor_data['post']);
187 - foreach ($elementor_data['meta'] as $key => $value) {
188 - if($key == '_elementor_css') continue;
189 - foreach ($value as $s_value) {
190 - if($key == '_elementor_data'){
191 - $s_value = wp_slash( wp_json_encode(json_decode($s_value)));
192 - }
193 - add_post_meta($el_id, $key, $s_value);
194 - }
217 + if(isset($post['source']) && $post['source'] == 'press_bar' && !empty($post['elementor_id'])){
218 + $el_id = $this->import_elementor_document(
219 + isset($data['elementor'][$post['elementor_id']]) ? $data['elementor'][$post['elementor_id']] : []
220 + );
221 + if($el_id){
222 + $post['elementor_id'] = $el_id;
195 223 }
196 - $post['elementor_id'] = $el_id;
197 -
198 -
224 + else{
225 + unset($post['elementor_id']);
226 + }
199 227 }
200 228
201 229
202 230 $notification = PostType::get_instance()->save_post($post); //, ['no_hooks' => true]
@@ -237,14 +265,30 @@
237 265 ];
238 266 }
239 267
240 268 public function export($request){
269 + // Importing/exporting many notifications can exceed the default limit.
270 + // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged
241 271 @set_time_limit(0);
242 272 $params = $request->get_params();
243 273 $export = [];
244 274 if(!empty($params['export-settings'])){
275 + if ( ! current_user_can( 'edit_notificationx_settings' ) ) {
276 + return new \WP_Error(
277 + 'nx_forbidden_settings_export',
278 + __( 'You are not allowed to export NotificationX settings.', 'notificationx' ),
279 + [ 'status' => 403 ]
280 + );
281 + }
245 282 $file_name = 'nx-settings-export.json';
246 - $export['settings'] = Settings::get_instance()->get('settings');
283 + /*
284 + * Credentials never travel in an export file. The download lands in
285 + * a Downloads folder and gets attached to support tickets; a live
286 + * OAuth refresh token or API key in there outlives any access
287 + * control the site applies. Import restores whatever the target site
288 + * already had, so a round trip does not blank integrations.
289 + */
290 + $export['settings'] = Settings::redact_secret_settings( Settings::get_instance()->get('settings') );
247 291 }
248 292 if(!empty($params['export-notification'])){
249 293 $where = [];
250 294 $file_name = 'nx-notification-export.json';
@@ -269,14 +313,30 @@
269 313 ]);
270 314 }
271 315
272 316 if(!empty($export['notifications'])){
273 - foreach ($export['notifications'] as $key => $post) {
274 - if($post['source'] == 'press_bar' && !empty($post['elementor_id'])){
275 - $export['elementor'][$post['elementor_id']]['post'] = get_post($post['elementor_id']);
317 + foreach ($export['notifications'] as $post) {
318 + if(isset($post['source']) && $post['source'] == 'press_bar' && !empty($post['elementor_id'])){
319 + /*
320 + * `elementor_id` is stored inside the notification's own
321 + * data blob, which is whatever the client submitted, and
322 + * `get_posts()` merges that blob up to the top level. So
323 + * this ID is attacker-controlled: without the type check
324 + * an `edit_notificationx` user could point it at any post
325 + * and read it back, with every meta row attached.
326 + */
327 + $linked = get_post( $post['elementor_id'] );
328 + if ( ! $linked || 'nx_bar' !== $linked->post_type ) {
329 + continue;
330 + }
331 +
332 + $export['elementor'][$post['elementor_id']]['post'] = $linked;
276 333 $meta = get_post_meta($post['elementor_id']);
277 - foreach ($meta as $key => $value) {
278 - $export['elementor'][$post['elementor_id']]['meta'][$key] = array_map('maybe_unserialize', $value);
334 + foreach ($meta as $meta_key => $value) {
335 + if ( ! in_array( $meta_key, self::ELEMENTOR_META_ALLOWLIST, true ) ) {
336 + continue;
337 + }
338 + $export['elementor'][$post['elementor_id']]['meta'][$meta_key] = array_map('maybe_unserialize', $value);
279 339 }
280 340 }
281 341 }
282 342 }
@@ -296,8 +356,102 @@
296 356 'export-status' => 'all',
297 357 ]
298 358 ]
299 359 ];
360 + }
361 +
362 + /**
363 + * Create the Elementor document that a `press_bar` notification links to.
364 + *
365 + * The previous implementation handed the client-supplied `post` array
366 + * straight to `wp_insert_post()` with only `ID` removed, so `post_type`,
367 + * `post_status` and `post_author` were all attacker-chosen -- an import file
368 + * could publish a page, authored by anyone, from a Contributor account. The
369 + * document is now built here and only its title is taken from the payload.
370 + *
371 + * @param array $document Untrusted `['post' => [...], 'meta' => [...]]`.
372 + * @return int New post ID, or 0 when nothing was created.
373 + */
374 + protected function import_elementor_document( $document ) {
375 + if ( empty( $document['post'] ) || ! is_array( $document['post'] ) ) {
376 + return 0;
377 + }
378 +
379 + $incoming = $document['post'];
380 + $title = isset( $incoming['post_title'] ) ? sanitize_text_field( $incoming['post_title'] ) : '';
381 + if ( '' === $title ) {
382 + $title = __( 'NotificationX Bar', 'notificationx' );
383 + }
384 +
385 + $el_id = wp_insert_post( [
386 + 'post_title' => wp_slash( $title ),
387 + 'post_content' => isset( $incoming['post_content'] ) ? wp_slash( (string) $incoming['post_content'] ) : '',
388 + 'post_type' => 'nx_bar',
389 + 'post_status' => current_user_can( 'publish_posts' ) ? 'publish' : 'pending',
390 + 'post_author' => get_current_user_id(),
391 + ], true );
392 +
393 + if ( is_wp_error( $el_id ) || ! $el_id ) {
394 + return 0;
395 + }
396 +
397 + /*
398 + * `_elementor_data` is a widget tree that Elementor renders on the front
399 + * end, and `add_post_meta()` applies no sanitising of its own. Elementor
400 + * gates raw markup on `unfiltered_html` in its own editor; mirror that
401 + * here so an import cannot become a route to stored XSS.
402 + */
403 + $allow_raw_html = current_user_can( 'unfiltered_html' );
404 + $meta = ( isset( $document['meta'] ) && is_array( $document['meta'] ) ) ? $document['meta'] : [];
405 +
406 + foreach ( $meta as $meta_key => $values ) {
407 + if ( ! in_array( $meta_key, self::ELEMENTOR_META_ALLOWLIST, true ) ) {
408 + continue;
409 + }
410 +
411 + foreach ( (array) $values as $value ) {
412 + if ( '_elementor_data' === $meta_key ) {
413 + $decoded = json_decode( is_string( $value ) ? $value : wp_json_encode( $value ), true );
414 + if ( null === $decoded ) {
415 + continue;
416 + }
417 + if ( ! $allow_raw_html ) {
418 + $decoded = self::kses_deep( $decoded );
419 + }
420 + $value = wp_slash( wp_json_encode( $decoded ) );
421 + }
422 + elseif ( is_string( $value ) && ! $allow_raw_html ) {
423 + $value = wp_kses_post( $value );
424 + }
425 +
426 + /*
427 + * `update_` rather than `add_`: every allowlisted key is
428 + * single-valued, and `wp_insert_post()` has already written its
429 + * own `_wp_page_template` row. Appending left the imported value
430 + * behind WordPress's, so `get_post_meta( ..., true )` returned
431 + * the default and the imported template never took effect.
432 + */
433 + update_post_meta( $el_id, $meta_key, $value );
434 + }
435 + }
436 +
437 + return $el_id;
438 + }
439 +
440 + /**
441 + * Run `wp_kses_post()` over every string in a nested structure.
442 + *
443 + * @param mixed $value
444 + * @return mixed
445 + */
446 + protected static function kses_deep( $value ) {
447 + if ( is_array( $value ) ) {
448 + return array_map( [ __CLASS__, 'kses_deep' ], $value );
449 + }
450 + if ( is_string( $value ) ) {
451 + return wp_kses_post( $value );
452 + }
453 + return $value;
300 454 }
301 455
302 456 public function group_stats_by_nx_id($stats){
303 457 $new_stats = [];