diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php index 96acb450ec..ecb50a21ab 100644 --- a/src/wp-includes/class-wp-customize-manager.php +++ b/src/wp-includes/class-wp-customize-manager.php @@ -1827,6 +1827,7 @@ final class WP_Customize_Manager { * @type string $status Post status. Optional. If supplied, the save will be transactional and a post revision will be allowed. * @type string $title Post title. Optional. * @type string $date_gmt Date in GMT. Optional. + * @type int $user_id ID for user who is saving the changeset. Optional, defaults to the current user ID. * } * * @return array|WP_Error Returns array on success and WP_Error with array data on error. @@ -1839,11 +1840,16 @@ final class WP_Customize_Manager { 'title' => null, 'data' => array(), 'date_gmt' => null, + 'user_id' => get_current_user_id(), ), $args ); $changeset_post_id = $this->changeset_post_id(); + $existing_changeset_data = array(); + if ( $changeset_post_id ) { + $existing_changeset_data = $this->get_changeset_post_data( $changeset_post_id ); + } // The request was made via wp.customize.previewer.save(). $update_transactionally = (bool) $args['status']; @@ -1863,6 +1869,37 @@ final class WP_Customize_Manager { ) ); $this->add_dynamic_settings( array_keys( $post_values ) ); // Ensure settings get created even if they lack an input value. + /* + * Get list of IDs for settings that have values different from what is currently + * saved in the changeset. By skipping any values that are already the same, the + * subset of changed settings can be passed into validate_setting_values to prevent + * an underprivileged modifying a single setting for which they have the capability + * from being blocked from saving. This also prevents a user from touching of the + * previous saved settings and overriding the associated user_id if they made no change. + */ + $changed_setting_ids = array(); + foreach ( $post_values as $setting_id => $setting_value ) { + $setting = $this->get_setting( $setting_id ); + + if ( $setting && 'theme_mod' === $setting->type ) { + $prefixed_setting_id = $this->get_stylesheet() . '::' . $setting->id; + } else { + $prefixed_setting_id = $setting_id; + } + + $is_value_changed = ( + ! isset( $existing_changeset_data[ $prefixed_setting_id ] ) + || + ! array_key_exists( 'value', $existing_changeset_data[ $prefixed_setting_id ] ) + || + $existing_changeset_data[ $prefixed_setting_id ]['value'] !== $setting_value + ); + if ( $is_value_changed ) { + $changed_setting_ids[] = $setting_id; + } + } + $post_values = wp_array_slice_assoc( $post_values, $changed_setting_ids ); + /** * Fires before save validation happens. * @@ -1943,7 +1980,10 @@ final class WP_Customize_Manager { $data[ $changeset_setting_id ] = array_merge( $data[ $changeset_setting_id ], $setting_params, - array( 'type' => $setting->type ) + array( + 'type' => $setting->type, + 'user_id' => $args['user_id'], + ) ); } } @@ -2121,29 +2161,38 @@ final class WP_Customize_Manager { $previous_changeset_data = $this->_changeset_data; $this->_changeset_data = $publishing_changeset_data; - // Ensure that other theme mods are stashed. - $other_theme_mod_settings = array(); - if ( did_action( 'switch_theme' ) ) { - $namespace_pattern = '/^(?P.+?)::(?P.+)$/'; - $matches = array(); - foreach ( $this->_changeset_data as $raw_setting_id => $setting_params ) { - $is_other_theme_mod = ( - isset( $setting_params['value'] ) - && - isset( $setting_params['type'] ) - && - 'theme_mod' === $setting_params['type'] - && - preg_match( $namespace_pattern, $raw_setting_id, $matches ) - && - $this->get_stylesheet() !== $matches['stylesheet'] - ); - if ( $is_other_theme_mod ) { - if ( ! isset( $other_theme_mod_settings[ $matches['stylesheet'] ] ) ) { - $other_theme_mod_settings[ $matches['stylesheet'] ] = array(); - } - $other_theme_mod_settings[ $matches['stylesheet'] ][ $matches['setting_id'] ] = $setting_params; + // Parse changeset data to identify theme mod settings and user IDs associated with settings to be saved. + $setting_user_ids = array(); + $theme_mod_settings = array(); + $namespace_pattern = '/^(?P.+?)::(?P.+)$/'; + $matches = array(); + foreach ( $this->_changeset_data as $raw_setting_id => $setting_params ) { + $actual_setting_id = null; + $is_theme_mod_setting = ( + isset( $setting_params['value'] ) + && + isset( $setting_params['type'] ) + && + 'theme_mod' === $setting_params['type'] + && + preg_match( $namespace_pattern, $raw_setting_id, $matches ) + ); + if ( $is_theme_mod_setting ) { + if ( ! isset( $theme_mod_settings[ $matches['stylesheet'] ] ) ) { + $theme_mod_settings[ $matches['stylesheet'] ] = array(); } + $theme_mod_settings[ $matches['stylesheet'] ][ $matches['setting_id'] ] = $setting_params; + + if ( $this->get_stylesheet() === $matches['stylesheet'] ) { + $actual_setting_id = $matches['setting_id']; + } + } else { + $actual_setting_id = $raw_setting_id; + } + + // Keep track of the user IDs for settings actually for this theme. + if ( $actual_setting_id && isset( $setting_params['user_id'] ) ) { + $setting_user_ids[ $actual_setting_id ] = $setting_params['user_id']; } } @@ -2173,21 +2222,38 @@ final class WP_Customize_Manager { $original_setting_capabilities = array(); foreach ( $changeset_setting_ids as $setting_id ) { $setting = $this->get_setting( $setting_id ); - if ( $setting ) { + if ( $setting && ! isset( $setting_user_ids[ $setting_id ] ) ) { $original_setting_capabilities[ $setting->id ] = $setting->capability; $setting->capability = 'exist'; } } + $original_user_id = get_current_user_id(); foreach ( $changeset_setting_ids as $setting_id ) { $setting = $this->get_setting( $setting_id ); if ( $setting ) { + /* + * Set the current user to match the user who saved the value into + * the changeset so that any filters that apply during the save + * process will respect the original user's capabilities. This + * will ensure, for example, that KSES won't strip unsafe HTML + * when a scheduled changeset publishes via WP Cron. + */ + if ( isset( $setting_user_ids[ $setting_id ] ) ) { + wp_set_current_user( $setting_user_ids[ $setting_id ] ); + } else { + wp_set_current_user( $original_user_id ); + } + $setting->save(); } } + wp_set_current_user( $original_user_id ); // Update the stashed theme mod settings, removing the active theme's stashed settings, if activated. if ( did_action( 'switch_theme' ) ) { + $other_theme_mod_settings = $theme_mod_settings; + unset( $other_theme_mod_settings[ $this->get_stylesheet() ] ); $this->update_stashed_theme_mod_settings( $other_theme_mod_settings ); } diff --git a/src/wp-includes/theme.php b/src/wp-includes/theme.php index 9c53441bf6..646b2e4553 100644 --- a/src/wp-includes/theme.php +++ b/src/wp-includes/theme.php @@ -2564,7 +2564,7 @@ function _wp_customize_publish_changeset( $new_status, $old_status, $changeset_p if ( empty( $wp_customize ) ) { require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; - $wp_customize = new WP_Customize_Manager( $changeset_post->post_name ); + $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $changeset_post->post_name ) ); } if ( ! did_action( 'customize_register' ) ) { diff --git a/tests/phpunit/tests/customize/manager.php b/tests/phpunit/tests/customize/manager.php index 3ace533ad6..950f29238f 100644 --- a/tests/phpunit/tests/customize/manager.php +++ b/tests/phpunit/tests/customize/manager.php @@ -429,22 +429,24 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase { $wp_customize = $manager = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid, ) ); + $wp_customize = $manager; $manager->register_controls(); $manager->set_post_value( 'blogname', 'Changeset Title' ); $manager->set_post_value( 'blogdescription', 'Changeset Tagline' ); + $pre_saved_data = array( + 'blogname' => array( + 'value' => 'Overridden Changeset Title', + ), + 'blogdescription' => array( + 'custom' => 'something', + ), + ); $r = $manager->save_changeset_post( array( 'status' => 'auto-draft', 'title' => 'Auto Draft', 'date_gmt' => '2010-01-01 00:00:00', - 'data' => array( - 'blogname' => array( - 'value' => 'Overridden Changeset Title', - ), - 'blogdescription' => array( - 'custom' => 'something', - ), - ), + 'data' => $pre_saved_data, ) ); $this->assertInternalType( 'array', $r ); @@ -454,8 +456,14 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase { $this->assertNotNull( $post_id ); $saved_data = json_decode( get_post( $post_id )->post_content, true ); $this->assertEquals( $manager->unsanitized_post_values(), wp_list_pluck( $saved_data, 'value' ) ); - $this->assertEquals( 'Overridden Changeset Title', $saved_data['blogname']['value'] ); - $this->assertEquals( 'something', $saved_data['blogdescription']['custom'] ); + $this->assertEquals( $pre_saved_data['blogname']['value'], $saved_data['blogname']['value'] ); + $this->assertEquals( $pre_saved_data['blogdescription']['custom'], $saved_data['blogdescription']['custom'] ); + foreach ( $saved_data as $setting_id => $setting_params ) { + $this->assertArrayHasKey( 'type', $setting_params ); + $this->assertEquals( 'option', $setting_params['type'] ); + $this->assertArrayHasKey( 'user_id', $setting_params ); + $this->assertEquals( self::$admin_user_id, $setting_params['user_id'] ); + } $this->assertEquals( 'Auto Draft', get_post( $post_id )->post_title ); $this->assertEquals( 'auto-draft', get_post( $post_id )->post_status ); $this->assertEquals( '2010-01-01 00:00:00', get_post( $post_id )->post_date_gmt ); @@ -511,6 +519,7 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase { $wp_customize = $manager = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid, ) ); + $wp_customize = $manager; $manager->register_controls(); // That is, register settings. $r = $manager->save_changeset_post( array( 'status' => null, @@ -563,12 +572,13 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase { } $wp_customize = $manager = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) ); - $manager->register_controls(); + do_action( 'customize_register', $wp_customize ); $manager->add_setting( 'scratchpad', array( 'type' => 'option', 'capability' => 'exist', ) ); $manager->get_setting( 'blogname' )->capability = 'exist'; + $original_capabilities = wp_list_pluck( $manager->settings(), 'capability' ); wp_set_current_user( self::$subscriber_user_id ); $r = $manager->save_changeset_post( array( 'status' => 'publish', @@ -584,6 +594,7 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase { $this->assertInternalType( 'array', $r ); $this->assertEquals( 'Do it live \o/', get_option( 'blogname' ) ); $this->assertEquals( 'trash', get_post_status( $post_id ) ); // Auto-trashed. + $this->assertEquals( $original_capabilities, wp_list_pluck( $manager->settings(), 'capability' ) ); $this->assertContains( '' ); + $wp_customize->save_changeset_post( array( + 'status' => 'auto-draft', + 'user_id' => self::$admin_user_id, + ) ); + $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $wp_customize->changeset_uuid() ) ); + do_action( 'customize_register', $wp_customize ); + $wp_customize->save_changeset_post( array( 'status' => 'publish' ) ); + $this->assertEquals( 'Unfiltered', get_option( 'scratchpad' ) ); + + // Attempt scratchpad with user who doesn't have unfiltered_html. + update_option( 'scratchpad', '' ); + $wp_customize = new WP_Customize_Manager(); + do_action( 'customize_register', $wp_customize ); + $wp_customize->set_post_value( 'scratchpad', 'Unfiltered' ); + $wp_customize->save_changeset_post( array( + 'status' => 'auto-draft', + 'user_id' => self::$subscriber_user_id, + ) ); + $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $wp_customize->changeset_uuid() ) ); + do_action( 'customize_register', $wp_customize ); + $wp_customize->save_changeset_post( array( 'status' => 'publish' ) ); + $this->assertEquals( 'Unfilteredevil', get_option( 'scratchpad' ) ); + + // Attempt publishing scratchpad as anonymous user when changeset was set by privileged user. + update_option( 'scratchpad', '' ); + $wp_customize = new WP_Customize_Manager(); + do_action( 'customize_register', $wp_customize ); + $wp_customize->set_post_value( 'scratchpad', 'Unfiltered' ); + $wp_customize->save_changeset_post( array( + 'status' => 'auto-draft', + 'user_id' => self::$admin_user_id, + ) ); + $changeset_post_id = $wp_customize->changeset_post_id(); + wp_set_current_user( 0 ); + $wp_customize = null; + unset( $GLOBALS['wp_actions']['customize_register'] ); + $this->assertEquals( 'Unfilteredevil', apply_filters( 'content_save_pre', 'Unfiltered' ) ); + wp_publish_post( $changeset_post_id ); // @todo If wp_update_post() is used here, then kses will corrupt the post_content. + $this->assertEquals( 'Unfiltered', get_option( 'scratchpad' ) ); + } + + /** + * Register scratchpad setting. + * + * @param WP_Customize_Manager $wp_customize Manager. + */ + function register_scratchpad_setting( WP_Customize_Manager $wp_customize ) { + $wp_customize->add_setting( 'scratchpad', array( + 'type' => 'option', + 'capability' => 'exist', + 'sanitize_callback' => array( $this, 'filter_sanitize_scratchpad' ), + ) ); + } + + /** + * Sanitize scratchpad as if it is post_content so kses filters apply. + * + * @param string $value Value. + * @return string Value. + */ + function filter_sanitize_scratchpad( $value ) { + return apply_filters( 'content_save_pre', $value ); + } + + /** + * Current user when settings are filtered. + * + * @var array + */ + protected $filtered_setting_current_user_ids = array(); + + /** + * Filter setting to capture the current user when the filter applies. + * + * @param mixed $value Setting value. + * @param WP_Customize_Setting $setting Setting. + * @return mixed Value. + */ + function filter_customize_setting_to_log_current_user( $value, $setting ) { + $this->filtered_setting_current_user_ids[ $setting->id ] = get_current_user_id(); + return $value; + } + /** * Test WP_Customize_Manager::is_cross_domain(). *