diff --git a/src/wp-includes/block-supports/custom-css.php b/src/wp-includes/block-supports/custom-css.php index 9d5b13426f4ef..b5d977113c863 100644 --- a/src/wp-includes/block-supports/custom-css.php +++ b/src/wp-includes/block-supports/custom-css.php @@ -124,6 +124,153 @@ function wp_register_custom_css_support( $block_type ) { } } +/** + * Strips `style.css` attributes from all blocks in post content. + * + * Uses WP_Block_Parser::next_token() to scan block tokens and surgically + * replace only the attribute JSON that changed — no parse_blocks() + + * serialize_blocks() round-trip needed. + * + * @since 7.0.0 + * @access private + * + * @param string $content Post content to filter, expected to be escaped with slashes. + * @return string Filtered post content with block custom CSS removed. + */ +function wp_strip_custom_css_from_blocks( $content ) { + if ( ! has_blocks( $content ) ) { + return $content; + } + + $unslashed = stripslashes( $content ); + + $parser = new WP_Block_Parser(); + $parser->document = $unslashed; + $parser->offset = 0; + $end = strlen( $unslashed ); + $replacements = array(); + + while ( $parser->offset < $end ) { + $next_token = $parser->next_token(); + list( $token_type, , $attrs, $start_offset, $token_length ) = $next_token; + + if ( 'no-more-tokens' === $token_type ) { + break; + } + + $parser->offset = $start_offset + $token_length; + + if ( 'block-opener' !== $token_type && 'void-block' !== $token_type ) { + continue; + } + + if ( ! isset( $attrs['style']['css'] ) ) { + continue; + } + + // Remove css and clean up empty style. + unset( $attrs['style']['css'] ); + if ( empty( $attrs['style'] ) ) { + unset( $attrs['style'] ); + } + + // Locate the JSON portion within the token. + $token_string = substr( $unslashed, $start_offset, $token_length ); + $json_rel_start = strcspn( $token_string, '{' ); + $json_rel_end = strrpos( $token_string, '}' ); + + $json_start = $start_offset + $json_rel_start; + $json_length = $json_rel_end - $json_rel_start + 1; + + // Re-encode attributes. If attrs is now empty, remove JSON and trailing space. + if ( empty( $attrs ) ) { + // Remove the trailing space after JSON. + $replacements[] = array( $json_start, $json_length + 1, '' ); + } else { + $replacements[] = array( $json_start, $json_length, serialize_block_attributes( $attrs ) ); + } + } + + if ( empty( $replacements ) ) { + return $content; + } + + // Build the result by splicing replacements into the original string. + $result = ''; + $was_at = 0; + + foreach ( $replacements as $replacement ) { + list( $offset, $length, $new_json ) = $replacement; + $result .= substr( $unslashed, $was_at, $offset - $was_at ) . $new_json; + $was_at = $offset + $length; + } + + if ( $was_at < $end ) { + $result .= substr( $unslashed, $was_at ); + } + + return addslashes( $result ); +} + +/** + * Adds the filters to strip custom CSS from block content on save. + * + * @since 7.0.0 + * @access private + */ +function wp_custom_css_kses_init_filters() { + add_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks', 8 ); + add_filter( 'content_filtered_save_pre', 'wp_strip_custom_css_from_blocks', 8 ); +} + +/** + * Removes the filters that strip custom CSS from block content on save. + * + * @since 7.0.0 + * @access private + */ +function wp_custom_css_remove_filters() { + remove_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks', 8 ); + remove_filter( 'content_filtered_save_pre', 'wp_strip_custom_css_from_blocks', 8 ); +} + +/** + * Registers the custom CSS content filters if the user does not have the edit_css capability. + * + * @since 7.0.0 + * @access private + */ +function wp_custom_css_kses_init() { + wp_custom_css_remove_filters(); + if ( ! current_user_can( 'edit_css' ) ) { + wp_custom_css_kses_init_filters(); + } +} + +/** + * Initializes custom CSS content filters when imported data should be filtered. + * + * This filter is the last being executed on force_filtered_html_on_import. + * If the input of the filter is true it means we are in an import situation and should + * enable the custom CSS filters, independently of the user capabilities. + * + * @since 7.0.0 + * @access private + * + * @param mixed $arg Input argument of the filter. + * @return mixed Input argument of the filter. + */ +function wp_custom_css_force_filtered_html_on_import_filter( $arg ) { + if ( $arg ) { + wp_custom_css_kses_init_filters(); + } + return $arg; +} + +add_action( 'init', 'wp_custom_css_kses_init', 20 ); +add_action( 'set_current_user', 'wp_custom_css_kses_init' ); +add_filter( 'force_filtered_html_on_import', 'wp_custom_css_force_filtered_html_on_import_filter', 999 ); + // Register the block support. WP_Block_Supports::get_instance()->register( 'custom-css', diff --git a/tests/phpunit/tests/block-supports/wpStripCustomCssFromBlocks.php b/tests/phpunit/tests/block-supports/wpStripCustomCssFromBlocks.php new file mode 100644 index 0000000000000..3d31843469642 --- /dev/null +++ b/tests/phpunit/tests/block-supports/wpStripCustomCssFromBlocks.php @@ -0,0 +1,129 @@ +assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'] ?? array(), $message ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_strips_css_from_blocks() { + return array( + 'single block' => array( + 'content' => '

Hello

', + 'message' => 'style.css should be stripped from block attributes.', + ), + 'empty style object is cleaned up' => array( + 'content' => '

Hello

', + 'message' => 'style.css should be stripped from block attributes.', + ), + ); + } + + /** + * Tests that style.css is stripped from nested inner blocks. + * + * @ticket 64771 + */ + public function test_strips_css_from_inner_blocks() { + $content = '

Hello

'; + + $result = wp_unslash( wp_strip_custom_css_from_blocks( $content ) ); + $blocks = parse_blocks( $result ); + + $inner_block = $blocks[0]['innerBlocks'][0]; + $this->assertArrayNotHasKey( 'css', $inner_block['attrs']['style'] ?? array(), 'style.css should be stripped from inner block attributes.' ); + } + + /** + * Tests that content without blocks is returned unchanged. + * + * @ticket 64771 + */ + public function test_returns_non_block_content_unchanged() { + $content = '

This is plain HTML content with no blocks.

'; + + $result = wp_strip_custom_css_from_blocks( $content ); + + $this->assertSame( $content, $result, 'Non-block content should be returned unchanged.' ); + } + + /** + * Tests that content without style.css attributes is returned unchanged. + * + * @ticket 64771 + */ + public function test_returns_unchanged_when_no_css_attributes() { + $content = '

Hello

'; + + $result = wp_strip_custom_css_from_blocks( $content ); + + $this->assertSame( $content, $result, 'Content without style.css attributes should be returned unchanged.' ); + } + + /** + * Tests that other style properties are preserved when css is stripped. + * + * @ticket 64771 + */ + public function test_preserves_other_style_properties() { + $content = '

Hello

'; + + $result = wp_unslash( wp_strip_custom_css_from_blocks( $content ) ); + $blocks = parse_blocks( $result ); + + $this->assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'], 'style.css should be stripped.' ); + $this->assertSame( '#ff0000', $blocks[0]['attrs']['style']['color']['text'], 'Other style properties should be preserved.' ); + } + + /** + * Tests that empty style object is cleaned up after stripping css. + * + * @ticket 64771 + */ + public function test_cleans_up_empty_style_object() { + $content = '

Hello

'; + + $result = wp_unslash( wp_strip_custom_css_from_blocks( $content ) ); + $blocks = parse_blocks( $result ); + + $this->assertArrayNotHasKey( 'style', $blocks[0]['attrs'], 'Empty style object should be cleaned up after stripping css.' ); + } + + /** + * Tests that slashed content is handled correctly. + * + * @ticket 64771 + */ + public function test_handles_slashed_content() { + $content = '

Hello

'; + $slashed = wp_slash( $content ); + + $result = wp_strip_custom_css_from_blocks( $slashed ); + $blocks = parse_blocks( wp_unslash( $result ) ); + + $this->assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'] ?? array(), 'style.css should be stripped even from slashed content.' ); + } +}