diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 1d77491c299f1..b42dda2f42ef6 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -2474,7 +2474,13 @@ function wp_filter_global_styles_post( $data ) { $data_to_encode = WP_Theme_JSON::remove_insecure_properties( $decoded_data, 'custom' ); $data_to_encode['isGlobalStylesUserThemeJSON'] = true; - return wp_slash( wp_json_encode( $data_to_encode ) ); + /** + * JSON encode the data stored in post content. + * Escape characters that are likely to be mangled by HTML filters: "<>&". + * + * This matches the escaping in {@see WP_REST_Global_Styles_Controller::prepare_item_for_database}. + */ + return wp_slash( wp_json_encode( $data_to_encode, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) ); } return $data; } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php index 2a3d4d340db5a..33e3632e1c4de 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php @@ -275,7 +275,14 @@ protected function prepare_item_for_database( $request ) { } $config['isGlobalStylesUserThemeJSON'] = true; $config['version'] = WP_Theme_JSON::LATEST_SCHEMA; - $changes->post_content = wp_json_encode( $config ); + /** + * JSON encode the data stored in post content. + * Escape characters that are likely to be mangled by HTML filters: "<>&". + * + * This data is later re-encoded by {@see wp_filter_global_styles_post}. + * The escaping is also applied here as a precaution. + */ + $changes->post_content = wp_json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); } // Post title. @@ -659,22 +666,51 @@ public function get_theme_items( $request ) { /** * Validate style.css as valid CSS. * - * Currently just checks for invalid markup. + * Currently just checks that CSS will not break an HTML STYLE tag. * * @since 6.2.0 * @since 6.4.0 Changed method visibility to protected. + * @since 7.0.0 Allow more CSS content. * * @param string $css CSS to validate. * @return true|WP_Error True if the input was validated, otherwise WP_Error. */ protected function validate_custom_css( $css ) { - if ( preg_match( '# 400 ) - ); + $length = strlen( $css ); + for ( + $at = strcspn( $css, '<' ); + $at < $length; + $at += strcspn( $css, '<', ++$at ) + ) { + $remaining_strlen = $length - $at; + $possible_style_close_tag = 0 === substr_compare( $css, ' 400 ) + ); + } + + if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) { + return new WP_Error( + 'rest_custom_css_illegal_markup', + sprintf( + /* translators: %s is the CSS that was provided. */ + __( 'The CSS must not contain "%s".' ), + esc_html( substr( $css, $at, 8 ) ) + ), + array( 'status' => 400 ) + ); + } + } } + return true; } } diff --git a/tests/phpunit/tests/rest-api/rest-global-styles-controller.php b/tests/phpunit/tests/rest-api/rest-global-styles-controller.php index 59986e597c71b..207b20f903372 100644 --- a/tests/phpunit/tests/rest-api/rest-global-styles-controller.php +++ b/tests/phpunit/tests/rest-api/rest-global-styles-controller.php @@ -650,6 +650,7 @@ public function test_update_item_valid_styles_css() { /** * @covers WP_REST_Global_Styles_Controller::update_item * @ticket 57536 + * @ticket 64418 */ public function test_update_item_invalid_styles_css() { wp_set_current_user( self::$admin_id ); @@ -659,7 +660,7 @@ public function test_update_item_invalid_styles_css() { $request = new WP_REST_Request( 'PUT', '/wp/v2/global-styles/' . self::$global_styles_id ); $request->set_body_params( array( - 'styles' => array( 'css' => '

test

body { color: red; }' ), + 'styles' => array( 'css' => '' ), ) ); $response = rest_get_server()->dispatch( $request ); @@ -826,4 +827,113 @@ public function test_global_styles_route_args_schema() { $this->assertArrayHasKey( 'type', $route_data[0]['args']['id'] ); $this->assertSame( 'integer', $route_data[0]['args']['id']['type'] ); } + + /** + * @covers WP_REST_Global_Styles_Controller::update_item + * @ticket 64418 + */ + public function test_update_allows_valid_css_with_more_syntax() { + wp_set_current_user( self::$admin_id ); + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + } + $request = new WP_REST_Request( 'PUT', '/wp/v2/global-styles/' . self::$global_styles_id ); + $css = <<<'CSS' +@property --animate { + syntax: ""; + inherits: true; + initial-value: false; +} +h1::before { content: "fun & games"; } +CSS; + $request->set_body_params( + array( + 'styles' => array( 'css' => $css ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( $css, $data['styles']['css'] ); + + // Compare expected API output to WP internal values. + $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/' . self::$global_styles_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( $css, $response->get_data()['styles']['css'] ); + } + + /** + * @covers WP_REST_Global_Styles_Controller::validate_custom_css + * @ticket 64418 + * + * @dataProvider data_custom_css_allowed + */ + public function test_validate_custom_css_allowed( string $custom_css ) { + $controller = new WP_REST_Global_Styles_Controller(); + $validate = Closure::bind( + function ( $css ) { + return $this->validate_custom_css( $css ); + }, + $controller, + $controller + ); + + $this->assertTrue( $validate( $custom_css ) ); + } + + public static function data_custom_css_allowed() { + return array( + '@property declaration' => array( + '@property --prop { syntax: ""; inherits: true; initial-value: false; }', + ), + 'Different close tag' => array( '' ), + 'Not a style close tag' => array( '/* array( '/* array( '' ), + 'Short content' => array( '/**/' ), + ); + } + + /** + * @covers WP_REST_Global_Styles_Controller::validate_custom_css + * @ticket 64418 + * + * @dataProvider data_custom_css_disallowed + */ + public function test_validate_custom_css( string $custom_css, string $expected_error_message ) { + $controller = new WP_REST_Global_Styles_Controller(); + $validate = Closure::bind( + function ( $css ) { + return $this->validate_custom_css( $css ); + }, + $controller, + $controller + ); + + $result = $validate( $custom_css ); + $this->assertWPError( $result ); + $this->assertSame( $expected_error_message, $result->get_error_message() ); + } + + public static function data_custom_css_disallowed() { + return array( + 'style close tag' => array( 'css……css', 'The CSS must not contain "</style>".' ), + 'style close tag upper case' => array( '', 'The CSS must not contain "</STYLE>".' ), + 'style close tag mixed case' => array( '', 'The CSS must not contain "</sTyLe>".' ), + 'style close tag in comment' => array( '/**/', 'The CSS must not contain "</style>".' ), + 'style close tag (/)' => array( ' array( " array( " array( " array( " array( ' array( '<', 'The CSS must not end in "<".' ), + 'truncated " array( ' array( ' array( ' array( ' array( ' array( '