Customize: Store modifying user ID with setting change written into changeset and restore current user when setting is being saved.

Restoring the current user context when saving a setting ensures filters apply as expected, such as Kses. When a user is not associated with a given setting change, continue to override `capability` to be `exist` when saving. Skip overwriting setting values in a changeset that have not changed, facilitating concurrent editing and amending a changeset by a user with fewer privileges.

See #30937.
Fixes #38705.


git-svn-id: https://develop.svn.wordpress.org/trunk@39181 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Weston Ruter 2016-11-09 07:02:53 +00:00
parent 865f3633f4
commit 8a0c502703
3 changed files with 344 additions and 39 deletions

View File

@ -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<stylesheet>.+?)::(?P<setting_id>.+)$/';
$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<stylesheet>.+?)::(?P<setting_id>.+)$/';
$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 );
}

View File

@ -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' ) ) {

View File

@ -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( '<script>', get_post( $post_id )->post_content );
$this->assertEquals( $manager->changeset_uuid(), get_post( $post_id )->post_name, 'Expected that the "__trashed" suffix to not be added.' );
wp_set_current_user( self::$admin_user_id );
@ -598,7 +609,7 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase {
add_post_type_support( 'customize_changeset', 'revisions' );
$uuid = wp_generate_uuid4();
$wp_customize = $manager = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
$manager->register_controls();
do_action( 'customize_register', $manager );
$manager->set_post_value( 'blogname', 'Hello Surface' );
$manager->save_changeset_post( array( 'status' => 'auto-draft' ) );
@ -660,6 +671,7 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase {
* @covers WP_Customize_Manager::update_stashed_theme_mod_settings()
*/
function test_save_changeset_post_with_theme_activation() {
global $wp_customize;
wp_set_current_user( self::$admin_user_id );
$preview_theme = $this->get_inactive_core_theme();
@ -676,8 +688,8 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase {
'changeset_uuid' => $uuid,
'theme' => $preview_theme,
) );
$manager->register_controls();
$GLOBALS['wp_customize'] = $manager;
$wp_customize = $manager;
do_action( 'customize_register', $manager );
$manager->set_post_value( 'blogname', 'Hello Preview Theme' );
$post_values = $manager->unsanitized_post_values();
@ -688,6 +700,233 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase {
$this->assertEquals( 'Hello Preview Theme', get_option( 'blogname' ) );
}
/**
* Test saving changesets with varying users and capabilities.
*
* @ticket 38705
* @covers WP_Customize_Manager::save_changeset_post()
*/
function test_save_changeset_post_with_varying_users() {
global $wp_customize;
add_theme_support( 'custom-background' );
wp_set_current_user( self::$admin_user_id );
$other_admin_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
$uuid = wp_generate_uuid4();
$manager = new WP_Customize_Manager( array(
'changeset_uuid' => $uuid,
) );
$wp_customize = $manager;
do_action( 'customize_register', $manager );
$manager->add_setting( 'scratchpad', array(
'type' => 'option',
'capability' => 'exist',
) );
// Create initial set of
$r = $manager->save_changeset_post( array(
'status' => 'auto-draft',
'data' => array(
'blogname' => array(
'value' => 'Admin 1 Title',
),
'scratchpad' => array(
'value' => 'Admin 1 Scratch',
),
'background_color' => array(
'value' => '#000000',
),
),
) );
$this->assertInternalType( 'array', $r );
$this->assertEquals(
array_fill_keys( array( 'blogname', 'scratchpad', 'background_color' ), true ),
$r['setting_validities']
);
$post_id = $manager->find_changeset_post_id( $uuid );
$data = json_decode( get_post( $post_id )->post_content, true );
$this->assertEquals( self::$admin_user_id, $data['blogname']['user_id'] );
$this->assertEquals( self::$admin_user_id, $data['scratchpad']['user_id'] );
$this->assertEquals( self::$admin_user_id, $data[ $this->manager->get_stylesheet() . '::background_color' ]['user_id'] );
// Attempt to save just one setting under a different user.
wp_set_current_user( $other_admin_user_id );
$r = $manager->save_changeset_post( array(
'status' => 'auto-draft',
'data' => array(
'blogname' => array(
'value' => 'Admin 2 Title',
),
'background_color' => array(
'value' => '#FFFFFF',
),
),
) );
$this->assertInternalType( 'array', $r );
$this->assertEquals(
array_fill_keys( array( 'blogname', 'background_color' ), true ),
$r['setting_validities']
);
$data = json_decode( get_post( $post_id )->post_content, true );
$this->assertEquals( 'Admin 2 Title', $data['blogname']['value'] );
$this->assertEquals( $other_admin_user_id, $data['blogname']['user_id'] );
$this->assertEquals( 'Admin 1 Scratch', $data['scratchpad']['value'] );
$this->assertEquals( self::$admin_user_id, $data['scratchpad']['user_id'] );
$this->assertEquals( '#FFFFFF', $data[ $this->manager->get_stylesheet() . '::background_color' ]['value'] );
$this->assertEquals( $other_admin_user_id, $data[ $this->manager->get_stylesheet() . '::background_color' ]['user_id'] );
// Attempt to save now as under-privileged user.
$r = $manager->save_changeset_post( array(
'status' => 'auto-draft',
'data' => array(
'scratchpad' => array(
'value' => 'Subscriber Scratch',
),
),
'user_id' => self::$subscriber_user_id,
) );
$this->assertInternalType( 'array', $r );
$this->assertEquals(
array_fill_keys( array( 'scratchpad' ), true ),
$r['setting_validities']
);
$data = json_decode( get_post( $post_id )->post_content, true );
$this->assertEquals( $other_admin_user_id, $data['blogname']['user_id'] );
$this->assertEquals( self::$subscriber_user_id, $data['scratchpad']['user_id'] );
$this->assertEquals( $other_admin_user_id, $data[ $this->manager->get_stylesheet() . '::background_color' ]['user_id'] );
// Manually update the changeset so that the user_id context is not included.
$data = json_decode( get_post( $post_id )->post_content, true );
$data['blogdescription']['value'] = 'Programmatically-supplied Tagline';
wp_update_post( wp_slash( array( 'ID' => $post_id, 'post_content' => wp_json_encode( $data ) ) ) );
// Ensure the modifying user set as the current user when each is saved, simulating WP Cron envronment.
wp_set_current_user( 0 );
$save_counts = array();
foreach ( array_keys( $data ) as $setting_id ) {
$setting_id = preg_replace( '/^.+::/', '', $setting_id );
$save_counts[ $setting_id ] = did_action( sprintf( 'customize_save_%s', $setting_id ) );
}
$this->filtered_setting_current_user_ids = array();
foreach ( $manager->settings() as $setting ) {
add_filter( sprintf( 'customize_sanitize_%s', $setting->id ), array( $this, 'filter_customize_setting_to_log_current_user' ), 10, 2 );
}
wp_update_post( array( 'ID' => $post_id, 'post_status' => 'publish' ) );
foreach ( array_keys( $data ) as $setting_id ) {
$setting_id = preg_replace( '/^.+::/', '', $setting_id );
$this->assertEquals( $save_counts[ $setting_id ] + 1, did_action( sprintf( 'customize_save_%s', $setting_id ) ), $setting_id );
}
$this->assertEqualSets( array( 'blogname', 'blogdescription', 'background_color', 'scratchpad' ), array_keys( $this->filtered_setting_current_user_ids ) );
$this->assertEquals( $other_admin_user_id, $this->filtered_setting_current_user_ids['blogname'] );
$this->assertEquals( 0, $this->filtered_setting_current_user_ids['blogdescription'] );
$this->assertEquals( self::$subscriber_user_id, $this->filtered_setting_current_user_ids['scratchpad'] );
$this->assertEquals( $other_admin_user_id, $this->filtered_setting_current_user_ids['background_color'] );
$this->assertEquals( 'Subscriber Scratch', get_option( 'scratchpad' ) );
}
/**
* Test writing changesets and publishing with users who can unfiltered_html and those who cannot.
*
* @ticket 38705
* @covers WP_Customize_Manager::save_changeset_post()
*/
function test_save_changeset_post_with_varying_unfiltered_html_cap() {
global $wp_customize;
grant_super_admin( self::$admin_user_id );
$this->assertTrue( user_can( self::$admin_user_id, 'unfiltered_html' ) );
$this->assertFalse( user_can( self::$subscriber_user_id, 'unfiltered_html' ) );
wp_set_current_user( 0 );
add_action( 'customize_register', array( $this, 'register_scratchpad_setting' ) );
// Attempt scratchpad with user who has unfiltered_html.
update_option( 'scratchpad', '' );
$wp_customize = new WP_Customize_Manager();
do_action( 'customize_register', $wp_customize );
$wp_customize->set_post_value( 'scratchpad', 'Unfiltered<script>evil</script>' );
$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<script>evil</script>', 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<script>evil</script>' );
$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<script>evil</script>' );
$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<script>evil</script>' ) );
wp_publish_post( $changeset_post_id ); // @todo If wp_update_post() is used here, then kses will corrupt the post_content.
$this->assertEquals( 'Unfiltered<script>evil</script>', 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().
*