Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions src/wp-includes/block-supports/custom-css.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,158 @@ function wp_register_custom_css_support( $block_type ) {
}
}

/**
* Strips `style.css` attributes from all blocks in post content.
*
* Uses {@see 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();

if ( 'no-more-tokens' === $next_token[0] ) {
break;
}
Comment thread
ramonjd marked this conversation as resolved.

list( $token_type, , $attrs, $start_offset, $token_length ) = $next_token;

$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.
Comment thread
ramonjd marked this conversation as resolved.
* Priority of 8 to run before wp_filter_global_styles_post (priority 9) and wp_filter_post_kses (priority 10).
*
* @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.
Comment thread
ramonjd marked this conversation as resolved.
* Priority of 8 to run before wp_filter_global_styles_post (priority 9) and wp_filter_post_kses (priority 10).
*
* @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.
*
* Runs at priority 999 on {@see 'force_filtered_html_on_import'} to ensure it
* fires after general KSES initialization, independently of user capabilities.
* 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;
}

// Run before wp_filter_global_styles_post (priority 9) and wp_filter_post_kses (priority 10).
add_action( 'init', 'wp_custom_css_kses_init', 20 );
Comment thread
ramonjd marked this conversation as resolved.
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',
Expand Down
238 changes: 238 additions & 0 deletions tests/phpunit/tests/block-supports/wpStripCustomCssFromBlocks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
<?php

/**
* @group block-supports
*
*/
class Tests_Block_Supports_WpStripCustomCssFromBlocks extends WP_UnitTestCase {

/**
* Tests that style.css is stripped from block attributes.
*
* @ticket 64771
*
* @covers ::wp_strip_custom_css_from_blocks
* @dataProvider data_strips_css_from_blocks
*
* @param string $content Post content containing blocks.
* @param string $message Assertion message.
*/
public function test_strips_css_from_blocks( $content, $message ) {
$result = wp_unslash( wp_strip_custom_css_from_blocks( $content ) );
$blocks = parse_blocks( $result );

$this->assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'] ?? array(), $message );
Comment thread
ramonjd marked this conversation as resolved.
$this->assertArrayNotHasKey( 'style', $blocks[0]['attrs'] ?? array(), 'style key should be fully removed when css was the only property.' );
}

/**
* Data provider.
*
* @return array
*/
public function data_strips_css_from_blocks() {
return array(
'single block' => array(
'content' => '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->',
'message' => 'style.css should be stripped from block attributes.',
),
);
}

/**
* Tests that style.css is stripped from nested inner blocks.
*
* @covers ::wp_strip_custom_css_from_blocks
* @ticket 64771
*/
public function test_strips_css_from_inner_blocks() {
$content = '<!-- wp:group --><div class="wp-block-group"><!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph --></div><!-- /wp:group -->';

$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.
*
* @covers ::wp_strip_custom_css_from_blocks
* @ticket 64771
*/
public function test_returns_non_block_content_unchanged() {
$content = '<p>This is plain HTML content with no blocks.</p>';

$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.
*
* @covers ::wp_strip_custom_css_from_blocks
* @ticket 64771
*/
public function test_returns_unchanged_when_no_css_attributes() {
$content = '<!-- wp:paragraph {"style":{"color":{"text":"#ff0000"}}} --><p class="has-text-color" style="color:#ff0000">Hello</p><!-- /wp:paragraph -->';

$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.
*
* @covers ::wp_strip_custom_css_from_blocks
* @ticket 64771
*/
public function test_preserves_other_style_properties() {
$content = '<!-- wp:paragraph {"style":{"css":"color: red;","color":{"text":"#ff0000"}}} --><p>Hello</p><!-- /wp:paragraph -->';

$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.
*
* @covers ::wp_strip_custom_css_from_blocks
* @ticket 64771
*/
public function test_cleans_up_empty_style_object() {
$content = '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->';

$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.
*
* @covers ::wp_strip_custom_css_from_blocks
* @ticket 64771
*/
public function test_handles_slashed_content() {
$content = '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->';
$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.' );
}

/**
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added these tests to cover that the filters are added, which I think is the core guarantee of this feature?

* Tests that the content_save_pre filter is added for a user without edit_css.
*
* @ticket 64771
*
* @covers ::wp_custom_css_kses_init
* @covers ::wp_custom_css_kses_init_filters
*/
public function test_filter_added_for_user_without_edit_css() {
$author_id = self::factory()->user->create( array( 'role' => 'author' ) );
wp_set_current_user( $author_id );
wp_custom_css_kses_init();

$this->assertSame( 8, has_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks' ), 'content_save_pre filter should be added at priority 8 for users without edit_css.' );
$this->assertSame( 8, has_filter( 'content_filtered_save_pre', 'wp_strip_custom_css_from_blocks' ), 'content_filtered_save_pre filter should be added at priority 8 for users without edit_css.' );

wp_set_current_user( 0 );
wp_custom_css_remove_filters();
}

/**
* Tests that the content_save_pre filter is not added for a user with edit_css.
*
* @ticket 64771
*
* @covers ::wp_custom_css_kses_init
* @covers ::wp_custom_css_remove_filters
*/
public function test_filter_not_added_for_user_with_edit_css() {
$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
if ( is_multisite() ) {
grant_super_admin( $admin_id );
}
wp_set_current_user( $admin_id );
wp_custom_css_kses_init();

$this->assertFalse( has_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks' ), 'content_save_pre filter should not be added for users with edit_css.' );
$this->assertFalse( has_filter( 'content_filtered_save_pre', 'wp_strip_custom_css_from_blocks' ), 'content_filtered_save_pre filter should not be added for users with edit_css.' );

if ( is_multisite() ) {
revoke_super_admin( $admin_id );
}
wp_set_current_user( 0 );
wp_custom_css_remove_filters();
}

/**
* Tests that switching to a user with edit_css removes the filter via the set_current_user action.
*
* wp_custom_css_kses_init() is hooked to set_current_user, so wp_set_current_user()
* alone should update the filter state without a manual call.
*
* @ticket 64771
*
* @covers ::wp_custom_css_kses_init
*/
public function test_set_current_user_action_triggers_reinit() {
$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
$author_id = self::factory()->user->create( array( 'role' => 'author' ) );
if ( is_multisite() ) {
grant_super_admin( $admin_id );
}

// Switching to a user without edit_css should add the filter via the set_current_user action.
wp_set_current_user( $author_id );
$this->assertNotFalse( has_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks' ), 'Filter should be active for user without edit_css.' );

// Switching to a user with edit_css should remove the filter via the set_current_user action.
wp_set_current_user( $admin_id );
$this->assertFalse( has_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks' ), 'Filter should be removed after switching to a user with edit_css.' );

if ( is_multisite() ) {
revoke_super_admin( $admin_id );
}
wp_set_current_user( 0 );
wp_custom_css_remove_filters();
}

/**
* Tests that the filter is enabled during import regardless of user capability.
*
* @ticket 64771
*
* @covers ::wp_custom_css_force_filtered_html_on_import_filter
*/
public function test_force_filtered_html_on_import_enables_filter_for_privileged_user() {
$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
if ( is_multisite() ) {
grant_super_admin( $admin_id );
}
wp_set_current_user( $admin_id );
wp_custom_css_kses_init();

$this->assertFalse( has_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks' ), 'Filter should not be active for admin before import.' );

apply_filters( 'force_filtered_html_on_import', true );

$this->assertNotFalse( has_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks' ), 'Filter should be enabled during import regardless of user capability.' );

if ( is_multisite() ) {
revoke_super_admin( $admin_id );
}
wp_set_current_user( 0 );
wp_custom_css_remove_filters();
}
}
Loading