| @@ -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 = []; |