Skip to content

Commit cfb690c

Browse files
committed
Customize: Add additional filters to Customizer to prevent JSON corruption.
This solution extends the wp_insert_post_data filter to pass in addition to the slashed/sanitized/processed data, and the slashed/sanitized/unprocessed data, to also pass the initial slashed/unsanitized/unprocessed data which was passed into wp_insert_post(). This then allows plugins to have complete control over how sanitization is performed based on the post type. Props westonruter, peterwilsoncc, sstoqnov, whyisjake, xknown. git-svn-id: https://develop.svn.wordpress.org/trunk@47633 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 833ce3b commit cfb690c

3 files changed

Lines changed: 232 additions & 25 deletions

File tree

src/wp-includes/class-wp-customize-manager.php

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2928,17 +2928,12 @@ function save_changeset_post( $args = array() ) {
29282928
* WP_Customize_Setting::save().
29292929
*/
29302930

2931-
// Prevent content filters from corrupting JSON in post_content.
2932-
$has_kses = ( false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ) );
2933-
if ( $has_kses ) {
2934-
kses_remove_filters();
2935-
}
2936-
$has_targeted_link_rel_filters = ( false !== has_filter( 'content_save_pre', 'wp_targeted_link_rel' ) );
2937-
if ( $has_targeted_link_rel_filters ) {
2938-
wp_remove_targeted_link_rel_filters();
2939-
}
2940-
2941-
// Note that updating a post with publish status will trigger WP_Customize_Manager::publish_changeset_values().
2931+
/*
2932+
* Update the changeset post. The publish_customize_changeset action will cause the settings in the
2933+
* changeset to be saved via WP_Customize_Setting::save(). Updating a post with publish status will
2934+
* trigger WP_Customize_Manager::publish_changeset_values().
2935+
*/
2936+
add_filter( 'wp_insert_post_data', array( $this, 'preserve_insert_changeset_post_content' ), 5, 3 );
29422937
if ( $changeset_post_id ) {
29432938
if ( $args['autosave'] && 'auto-draft' !== get_post_status( $changeset_post_id ) ) {
29442939
// See _wp_translate_postdata() for why this is required as it will use the edit_post meta capability.
@@ -2969,14 +2964,7 @@ function save_changeset_post( $args = array() ) {
29692964
$this->_changeset_post_id = $r; // Update cached post ID for the loaded changeset.
29702965
}
29712966
}
2972-
2973-
// Restore removed content filters.
2974-
if ( $has_kses ) {
2975-
kses_init_filters();
2976-
}
2977-
if ( $has_targeted_link_rel_filters ) {
2978-
wp_init_targeted_link_rel_filters();
2979-
}
2967+
remove_filter( 'wp_insert_post_data', array( $this, 'preserve_insert_changeset_post_content' ), 5 );
29802968

29812969
$this->_changeset_data = null; // Reset so WP_Customize_Manager::changeset_data() will re-populate with updated contents.
29822970

@@ -2994,6 +2982,51 @@ function save_changeset_post( $args = array() ) {
29942982
return $response;
29952983
}
29962984

2985+
/**
2986+
* Preserve the initial JSON post_content passed to save into the post.
2987+
*
2988+
* This is needed to prevent KSES and other {@see 'content_save_pre'} filters
2989+
* from corrupting JSON data.
2990+
*
2991+
* Note that WP_Customize_Manager::validate_setting_values() have already
2992+
* run on the setting values being serialized as JSON into the post content
2993+
* so it is pre-sanitized.
2994+
*
2995+
* Also, the sanitization logic is re-run through the respective
2996+
* WP_Customize_Setting::sanitize() method when being read out of the
2997+
* changeset, via WP_Customize_Manager::post_value(), and this sanitized
2998+
* value will also be sent into WP_Customize_Setting::update() for
2999+
* persisting to the DB.
3000+
*
3001+
* Multiple users can collaborate on a single changeset, where one user may
3002+
* have the unfiltered_html capability but another may not. A user with
3003+
* unfiltered_html may add a script tag to some field which needs to be kept
3004+
* intact even when another user updates the changeset to modify another field
3005+
* when they do not have unfiltered_html.
3006+
*
3007+
* @since 5.4.1
3008+
*
3009+
* @param array $data An array of slashed and processed post data.
3010+
* @param array $postarr An array of sanitized (and slashed) but otherwise unmodified post data.
3011+
* @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as originally passed to wp_insert_post().
3012+
* @return array Filtered post data.
3013+
*/
3014+
public function preserve_insert_changeset_post_content( $data, $postarr, $unsanitized_postarr ) {
3015+
if (
3016+
isset( $data['post_type'] ) &&
3017+
isset( $unsanitized_postarr['post_content'] ) &&
3018+
'customize_changeset' === $data['post_type'] ||
3019+
(
3020+
'revision' === $data['post_type'] &&
3021+
! empty( $data['post_parent'] ) &&
3022+
'customize_changeset' === get_post_type( $data['post_parent'] )
3023+
)
3024+
) {
3025+
$data['post_content'] = $unsanitized_postarr['post_content'];
3026+
}
3027+
return $data;
3028+
}
3029+
29973030
/**
29983031
* Trash or delete a changeset post.
29993032
*

src/wp-includes/post.php

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3594,6 +3594,9 @@ function wp_get_recent_posts( $args = array(), $output = ARRAY_A ) {
35943594
function wp_insert_post( $postarr, $wp_error = false ) {
35953595
global $wpdb;
35963596

3597+
// Capture original pre-sanitized array for passing into filters.
3598+
$unsanitized_postarr = $postarr;
3599+
35973600
$user_id = get_current_user_id();
35983601

35993602
$defaults = array(
@@ -3918,21 +3921,27 @@ function wp_insert_post( $postarr, $wp_error = false ) {
39183921
* Filters attachment post data before it is updated in or added to the database.
39193922
*
39203923
* @since 3.9.0
3924+
* @since 5.4.1 `$unsanitized_postarr` argument added.
39213925
*
3922-
* @param array $data An array of sanitized attachment post data.
3923-
* @param array $postarr An array of unsanitized attachment post data.
3926+
* @param array $data An array of slashed, sanitized, and processed attachment post data.
3927+
* @param array $postarr An array of slashed and sanitized attachment post data, but not processed.
3928+
* @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed attachment post data
3929+
* as originally passed to wp_insert_post().
39243930
*/
3925-
$data = apply_filters( 'wp_insert_attachment_data', $data, $postarr );
3931+
$data = apply_filters( 'wp_insert_attachment_data', $data, $postarr, $unsanitized_postarr );
39263932
} else {
39273933
/**
39283934
* Filters slashed post data just before it is inserted into the database.
39293935
*
39303936
* @since 2.7.0
3937+
* @since 5.4.1 `$unsanitized_postarr` argument added.
39313938
*
3932-
* @param array $data An array of slashed post data.
3933-
* @param array $postarr An array of sanitized, but otherwise unmodified post data.
3939+
* @param array $data An array of slashed, sanitized, and processed post data.
3940+
* @param array $postarr An array of sanitized (and slashed) but otherwise unmodified post data.
3941+
* @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as
3942+
* originally passed to wp_insert_post().
39343943
*/
3935-
$data = apply_filters( 'wp_insert_post_data', $data, $postarr );
3944+
$data = apply_filters( 'wp_insert_post_data', $data, $postarr, $unsanitized_postarr );
39363945
}
39373946
$data = wp_unslash( $data );
39383947
$where = array( 'ID' => $post_ID );

tests/phpunit/tests/customize/manager.php

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,171 @@ function test_save_changeset_post_without_theme_activation() {
12401240
$this->assertCount( 3, wp_get_post_revisions( $manager->changeset_post_id() ) );
12411241
}
12421242

1243+
/**
1244+
* Test saving changeset post without Kses or other content_save_pre filters mutating content.
1245+
*
1246+
* @covers WP_Customize_Manager::save_changeset_post()
1247+
*/
1248+
public function test_save_changeset_post_without_kses_corrupting_json() {
1249+
global $wp_customize;
1250+
$lesser_admin_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
1251+
1252+
$uuid = wp_generate_uuid4();
1253+
$wp_customize = new WP_Customize_Manager(
1254+
array(
1255+
'changeset_uuid' => $uuid,
1256+
)
1257+
);
1258+
1259+
add_filter( 'map_meta_cap', array( $this, 'filter_map_meta_cap_to_disallow_unfiltered_html' ), 10, 2 );
1260+
kses_init();
1261+
add_filter( 'content_save_pre', 'capital_P_dangit' );
1262+
add_post_type_support( 'customize_changeset', 'revisions' );
1263+
1264+
$options = array(
1265+
'custom_html_1' => '<script>document.write(" Wordpress 1")</script>',
1266+
'custom_html_2' => '<script>document.write(" Wordpress 2")</script>',
1267+
'custom_html_3' => '<script>document.write(" Wordpress 3")</script>',
1268+
);
1269+
1270+
// Populate setting as user who can bypass content_save_pre filter.
1271+
wp_set_current_user( self::$admin_user_id );
1272+
$wp_customize = $this->get_manager_for_testing_json_corruption_protection( $uuid );
1273+
$wp_customize->set_post_value( 'custom_html_1', $options['custom_html_1'] );
1274+
$wp_customize->save_changeset_post(
1275+
array(
1276+
'status' => 'draft',
1277+
)
1278+
);
1279+
1280+
// Populate setting as user who cannot bypass content_save_pre filter.
1281+
wp_set_current_user( $lesser_admin_user_id );
1282+
$wp_customize = $this->get_manager_for_testing_json_corruption_protection( $uuid );
1283+
$wp_customize->set_post_value( 'custom_html_2', $options['custom_html_2'] );
1284+
$wp_customize->save_changeset_post(
1285+
array(
1286+
'autosave' => true,
1287+
)
1288+
);
1289+
1290+
/*
1291+
* Ensure that the unsanitized value (the "POST data") is preserved in the autosave revision.
1292+
* The value is sent through the sanitize function when it is read from the changeset.
1293+
*/
1294+
$autosave_revision = wp_get_post_autosave( $wp_customize->changeset_post_id(), get_current_user_id() );
1295+
$saved_data = json_decode( $autosave_revision->post_content, true );
1296+
$this->assertEquals( $options['custom_html_1'], $saved_data['custom_html_1']['value'] );
1297+
$this->assertEquals( $options['custom_html_2'], $saved_data['custom_html_2']['value'] );
1298+
1299+
// Update post to discard autosave.
1300+
$wp_customize->save_changeset_post(
1301+
array(
1302+
'status' => 'draft',
1303+
)
1304+
);
1305+
1306+
/*
1307+
* Ensure that the unsanitized value (the "POST data") is preserved in the post content.
1308+
* The value is sent through the sanitize function when it is read from the changeset.
1309+
*/
1310+
$wp_customize = $this->get_manager_for_testing_json_corruption_protection( $uuid );
1311+
$saved_data = json_decode( get_post( $wp_customize->changeset_post_id() )->post_content, true );
1312+
$this->assertEquals( $options['custom_html_1'], $saved_data['custom_html_1']['value'] );
1313+
$this->assertEquals( $options['custom_html_2'], $saved_data['custom_html_2']['value'] );
1314+
1315+
/*
1316+
* Ensure that the unsanitized value (the "POST data") is preserved in the revisions' content.
1317+
* The value is sent through the sanitize function when it is read from the changeset.
1318+
*/
1319+
$revisions = wp_get_post_revisions( $wp_customize->changeset_post_id() );
1320+
$revision = array_shift( $revisions );
1321+
$saved_data = json_decode( $revision->post_content, true );
1322+
$this->assertEquals( $options['custom_html_1'], $saved_data['custom_html_1']['value'] );
1323+
$this->assertEquals( $options['custom_html_2'], $saved_data['custom_html_2']['value'] );
1324+
1325+
/*
1326+
* Now when publishing the changeset, the unsanitized values will be read from the changeset
1327+
* and sanitized according to the capabilities of the users who originally updated each
1328+
* setting in the changeset to begin with.
1329+
*/
1330+
wp_set_current_user( $lesser_admin_user_id );
1331+
$wp_customize = $this->get_manager_for_testing_json_corruption_protection( $uuid );
1332+
$wp_customize->set_post_value( 'custom_html_3', $options['custom_html_3'] );
1333+
$wp_customize->save_changeset_post(
1334+
array(
1335+
'status' => 'publish',
1336+
)
1337+
);
1338+
1339+
// User saved as one who can bypass content_save_pre filter.
1340+
$this->assertContains( '<script>', get_option( 'custom_html_1' ) );
1341+
$this->assertContains( 'Wordpress', get_option( 'custom_html_1' ) ); // phpcs:ignore WordPress.WP.CapitalPDangit.Misspelled
1342+
1343+
// User saved as one who cannot bypass content_save_pre filter.
1344+
$this->assertNotContains( '<script>', get_option( 'custom_html_2' ) );
1345+
$this->assertContains( 'WordPress', get_option( 'custom_html_2' ) );
1346+
1347+
// User saved as one who also cannot bypass content_save_pre filter.
1348+
$this->assertNotContains( '<script>', get_option( 'custom_html_3' ) );
1349+
$this->assertContains( 'WordPress', get_option( 'custom_html_3' ) );
1350+
}
1351+
1352+
/**
1353+
* Get a manager for testing JSON corruption protection.
1354+
*
1355+
* @param string $uuid UUID.
1356+
* @return WP_Customize_Manager Manager.
1357+
*/
1358+
private function get_manager_for_testing_json_corruption_protection( $uuid ) {
1359+
global $wp_customize;
1360+
$wp_customize = new WP_Customize_Manager(
1361+
array(
1362+
'changeset_uuid' => $uuid,
1363+
)
1364+
);
1365+
for ( $i = 0; $i < 5; $i++ ) {
1366+
$wp_customize->add_setting(
1367+
sprintf( 'custom_html_%d', $i ),
1368+
array(
1369+
'type' => 'option',
1370+
'sanitize_callback' => array( $this, 'apply_content_save_pre_filters_if_not_main_admin_user' ),
1371+
)
1372+
);
1373+
}
1374+
return $wp_customize;
1375+
}
1376+
1377+
/**
1378+
* Sanitize content with Kses if the current user is not the main admin.
1379+
*
1380+
* @since 5.2.?
1381+
*
1382+
* @param string $content Content to sanitize.
1383+
* @return string Sanitized content.
1384+
*/
1385+
public function apply_content_save_pre_filters_if_not_main_admin_user( $content ) {
1386+
if ( get_current_user_id() !== self::$admin_user_id ) {
1387+
$content = apply_filters( 'content_save_pre', $content );
1388+
}
1389+
return $content;
1390+
}
1391+
1392+
/**
1393+
* Filter map_meta_cap to disallow unfiltered_html.
1394+
*
1395+
* @since 5.2.?
1396+
*
1397+
* @param array $caps User's capabilities.
1398+
* @param string $cap Requested cap.
1399+
* @return array Caps.
1400+
*/
1401+
public function filter_map_meta_cap_to_disallow_unfiltered_html( $caps, $cap ) {
1402+
if ( 'unfiltered_html' === $cap ) {
1403+
$caps = array( 'do_not_allow' );
1404+
}
1405+
return $caps;
1406+
}
1407+
12431408
/**
12441409
* Call count for customize_changeset_save_data filter.
12451410
*

0 commit comments

Comments
 (0)