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 +173 -23 3.2.13trunk 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']);
@@ -166,9 +184,24 @@
166 184 try {
167 185 $data = json_decode($params['import'], true);
168 186
169 187 if(!empty($data['settings'])){
170 - 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'] );
171 204 $status = 'success';
172 205 }
173 206
174 207 if(!empty($data['notifications'])){
@@ -180,25 +213,18 @@
180 213 $nx_id = $post['nx_id'];
181 214 unset($post['nx_id']);
182 215 unset($post['id']);
183 216
184 - if($post['source'] == 'press_bar' && !empty($post['elementor_id'])){
185 - $elementor_data = $data['elementor'][$post['elementor_id']];
186 - unset($elementor_data['post']['ID']);
187 -
188 - $el_id = wp_insert_post($elementor_data['post']);
189 - foreach ($elementor_data['meta'] as $key => $value) {
190 - if($key == '_elementor_css') continue;
191 - foreach ($value as $s_value) {
192 - if($key == '_elementor_data'){
193 - $s_value = wp_slash( wp_json_encode(json_decode($s_value)));
194 - }
195 - add_post_meta($el_id, $key, $s_value);
196 - }
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;
197 223 }
198 - $post['elementor_id'] = $el_id;
199 -
200 -
224 + else{
225 + unset($post['elementor_id']);
226 + }
201 227 }
202 228
203 229
204 230 $notification = PostType::get_instance()->save_post($post); //, ['no_hooks' => true]
@@ -245,10 +271,24 @@
245 271 @set_time_limit(0);
246 272 $params = $request->get_params();
247 273 $export = [];
248 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 + }
249 282 $file_name = 'nx-settings-export.json';
250 - $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') );
251 291 }
252 292 if(!empty($params['export-notification'])){
253 293 $where = [];
254 294 $file_name = 'nx-notification-export.json';
@@ -273,14 +313,30 @@
273 313 ]);
274 314 }
275 315
276 316 if(!empty($export['notifications'])){
277 - foreach ($export['notifications'] as $key => $post) {
278 - if($post['source'] == 'press_bar' && !empty($post['elementor_id'])){
279 - $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;
280 333 $meta = get_post_meta($post['elementor_id']);
281 - foreach ($meta as $key => $value) {
282 - $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);
283 339 }
284 340 }
285 341 }
286 342 }
@@ -300,8 +356,102 @@
300 356 'export-status' => 'all',
301 357 ]
302 358 ]
303 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;
304 454 }
305 455
306 456 public function group_stats_by_nx_id($stats){
307 457 $new_stats = [];