Skip to content

Commit 88fe6bf

Browse files
committed
Block Supports: strip custom CSS from blocks for users without edit_css capability
Adds capability-gated CSS stripping so that when a user without `edit_css` saves a post, any `style.css` block attributes are removed from block comments using `WP_Block_Parser::next_token()`. Props aaronrobertshaw, audrasjb, dmsnell, glendaviesnz, jonsurrell, ozgursar, ramonopoly, shailu25, westonruter. Follow-up to [64544]. Fixes #64771. git-svn-id: https://develop.svn.wordpress.org/trunk@62257 602fd350-edb4-49c9-b593-d223f7449a82
1 parent a747d74 commit 88fe6bf

2 files changed

Lines changed: 390 additions & 0 deletions

File tree

src/wp-includes/block-supports/custom-css.php

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,158 @@ function wp_register_custom_css_support( $block_type ) {
124124
}
125125
}
126126

127+
/**
128+
* Strips `style.css` attributes from all blocks in post content.
129+
*
130+
* Uses {@see WP_Block_Parser::next_token()} to scan block tokens and surgically
131+
* replace only the attribute JSON that changed — no parse_blocks() +
132+
* serialize_blocks() round-trip needed.
133+
*
134+
* @since 7.0.0
135+
* @access private
136+
*
137+
* @param string $content Post content to filter, expected to be escaped with slashes.
138+
* @return string Filtered post content with block custom CSS removed.
139+
*/
140+
function wp_strip_custom_css_from_blocks( $content ) {
141+
if ( ! has_blocks( $content ) ) {
142+
return $content;
143+
}
144+
145+
$unslashed = stripslashes( $content );
146+
147+
$parser = new WP_Block_Parser();
148+
$parser->document = $unslashed;
149+
$parser->offset = 0;
150+
$end = strlen( $unslashed );
151+
$replacements = array();
152+
153+
while ( $parser->offset < $end ) {
154+
$next_token = $parser->next_token();
155+
156+
if ( 'no-more-tokens' === $next_token[0] ) {
157+
break;
158+
}
159+
160+
list( $token_type, , $attrs, $start_offset, $token_length ) = $next_token;
161+
162+
$parser->offset = $start_offset + $token_length;
163+
164+
if ( 'block-opener' !== $token_type && 'void-block' !== $token_type ) {
165+
continue;
166+
}
167+
168+
if ( ! isset( $attrs['style']['css'] ) ) {
169+
continue;
170+
}
171+
172+
// Remove css and clean up empty style.
173+
unset( $attrs['style']['css'] );
174+
if ( empty( $attrs['style'] ) ) {
175+
unset( $attrs['style'] );
176+
}
177+
178+
// Locate the JSON portion within the token.
179+
$token_string = substr( $unslashed, $start_offset, $token_length );
180+
$json_rel_start = strcspn( $token_string, '{' );
181+
$json_rel_end = strrpos( $token_string, '}' );
182+
183+
$json_start = $start_offset + $json_rel_start;
184+
$json_length = $json_rel_end - $json_rel_start + 1;
185+
186+
// Re-encode attributes. If attrs is now empty, remove JSON and trailing space.
187+
if ( empty( $attrs ) ) {
188+
// Remove the trailing space after JSON.
189+
$replacements[] = array( $json_start, $json_length + 1, '' );
190+
} else {
191+
$replacements[] = array( $json_start, $json_length, serialize_block_attributes( $attrs ) );
192+
}
193+
}
194+
195+
if ( empty( $replacements ) ) {
196+
return $content;
197+
}
198+
199+
// Build the result by splicing replacements into the original string.
200+
$result = '';
201+
$was_at = 0;
202+
203+
foreach ( $replacements as $replacement ) {
204+
list( $offset, $length, $new_json ) = $replacement;
205+
$result .= substr( $unslashed, $was_at, $offset - $was_at ) . $new_json;
206+
$was_at = $offset + $length;
207+
}
208+
209+
if ( $was_at < $end ) {
210+
$result .= substr( $unslashed, $was_at );
211+
}
212+
213+
return addslashes( $result );
214+
}
215+
216+
/**
217+
* Adds the filters to strip custom CSS from block content on save.
218+
* Priority of 8 to run before wp_filter_global_styles_post (priority 9) and wp_filter_post_kses (priority 10).
219+
*
220+
* @since 7.0.0
221+
* @access private
222+
*/
223+
function wp_custom_css_kses_init_filters() {
224+
add_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks', 8 );
225+
add_filter( 'content_filtered_save_pre', 'wp_strip_custom_css_from_blocks', 8 );
226+
}
227+
228+
/**
229+
* Removes the filters that strip custom CSS from block content on save.
230+
* Priority of 8 to run before wp_filter_global_styles_post (priority 9) and wp_filter_post_kses (priority 10).
231+
*
232+
* @since 7.0.0
233+
* @access private
234+
*/
235+
function wp_custom_css_remove_filters() {
236+
remove_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks', 8 );
237+
remove_filter( 'content_filtered_save_pre', 'wp_strip_custom_css_from_blocks', 8 );
238+
}
239+
240+
/**
241+
* Registers the custom CSS content filters if the user does not have the edit_css capability.
242+
*
243+
* @since 7.0.0
244+
* @access private
245+
*/
246+
function wp_custom_css_kses_init() {
247+
wp_custom_css_remove_filters();
248+
if ( ! current_user_can( 'edit_css' ) ) {
249+
wp_custom_css_kses_init_filters();
250+
}
251+
}
252+
253+
/**
254+
* Initializes custom CSS content filters when imported data should be filtered.
255+
*
256+
* Runs at priority 999 on {@see 'force_filtered_html_on_import'} to ensure it
257+
* fires after general KSES initialization, independently of user capabilities.
258+
* If the input of the filter is true it means we are in an import situation and should
259+
* enable the custom CSS filters, independently of the user capabilities.
260+
*
261+
* @since 7.0.0
262+
* @access private
263+
*
264+
* @param mixed $arg Input argument of the filter.
265+
* @return mixed Input argument of the filter.
266+
*/
267+
function wp_custom_css_force_filtered_html_on_import_filter( $arg ) {
268+
if ( $arg ) {
269+
wp_custom_css_kses_init_filters();
270+
}
271+
return $arg;
272+
}
273+
274+
// Run before wp_filter_global_styles_post (priority 9) and wp_filter_post_kses (priority 10).
275+
add_action( 'init', 'wp_custom_css_kses_init', 20 );
276+
add_action( 'set_current_user', 'wp_custom_css_kses_init' );
277+
add_filter( 'force_filtered_html_on_import', 'wp_custom_css_force_filtered_html_on_import_filter', 999 );
278+
127279
// Register the block support.
128280
WP_Block_Supports::get_instance()->register(
129281
'custom-css',
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
<?php
2+
3+
/**
4+
* @group block-supports
5+
*
6+
*/
7+
class Tests_Block_Supports_WpStripCustomCssFromBlocks extends WP_UnitTestCase {
8+
9+
/**
10+
* Tests that style.css is stripped from block attributes.
11+
*
12+
* @ticket 64771
13+
*
14+
* @covers ::wp_strip_custom_css_from_blocks
15+
* @dataProvider data_strips_css_from_blocks
16+
*
17+
* @param string $content Post content containing blocks.
18+
* @param string $message Assertion message.
19+
*/
20+
public function test_strips_css_from_blocks( $content, $message ) {
21+
$result = wp_unslash( wp_strip_custom_css_from_blocks( $content ) );
22+
$blocks = parse_blocks( $result );
23+
24+
$this->assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'] ?? array(), $message );
25+
$this->assertArrayNotHasKey( 'style', $blocks[0]['attrs'] ?? array(), 'style key should be fully removed when css was the only property.' );
26+
}
27+
28+
/**
29+
* Data provider.
30+
*
31+
* @return array
32+
*/
33+
public function data_strips_css_from_blocks() {
34+
return array(
35+
'single block' => array(
36+
'content' => '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->',
37+
'message' => 'style.css should be stripped from block attributes.',
38+
),
39+
);
40+
}
41+
42+
/**
43+
* Tests that style.css is stripped from nested inner blocks.
44+
*
45+
* @covers ::wp_strip_custom_css_from_blocks
46+
* @ticket 64771
47+
*/
48+
public function test_strips_css_from_inner_blocks() {
49+
$content = '<!-- wp:group --><div class="wp-block-group"><!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph --></div><!-- /wp:group -->';
50+
51+
$result = wp_unslash( wp_strip_custom_css_from_blocks( $content ) );
52+
$blocks = parse_blocks( $result );
53+
54+
$inner_block = $blocks[0]['innerBlocks'][0];
55+
$this->assertArrayNotHasKey( 'css', $inner_block['attrs']['style'] ?? array(), 'style.css should be stripped from inner block attributes.' );
56+
}
57+
58+
/**
59+
* Tests that content without blocks is returned unchanged.
60+
*
61+
* @covers ::wp_strip_custom_css_from_blocks
62+
* @ticket 64771
63+
*/
64+
public function test_returns_non_block_content_unchanged() {
65+
$content = '<p>This is plain HTML content with no blocks.</p>';
66+
67+
$result = wp_strip_custom_css_from_blocks( $content );
68+
69+
$this->assertSame( $content, $result, 'Non-block content should be returned unchanged.' );
70+
}
71+
72+
/**
73+
* Tests that content without style.css attributes is returned unchanged.
74+
*
75+
* @covers ::wp_strip_custom_css_from_blocks
76+
* @ticket 64771
77+
*/
78+
public function test_returns_unchanged_when_no_css_attributes() {
79+
$content = '<!-- wp:paragraph {"style":{"color":{"text":"#ff0000"}}} --><p class="has-text-color" style="color:#ff0000">Hello</p><!-- /wp:paragraph -->';
80+
81+
$result = wp_strip_custom_css_from_blocks( $content );
82+
83+
$this->assertSame( $content, $result, 'Content without style.css attributes should be returned unchanged.' );
84+
}
85+
86+
/**
87+
* Tests that other style properties are preserved when css is stripped.
88+
*
89+
* @covers ::wp_strip_custom_css_from_blocks
90+
* @ticket 64771
91+
*/
92+
public function test_preserves_other_style_properties() {
93+
$content = '<!-- wp:paragraph {"style":{"css":"color: red;","color":{"text":"#ff0000"}}} --><p>Hello</p><!-- /wp:paragraph -->';
94+
95+
$result = wp_unslash( wp_strip_custom_css_from_blocks( $content ) );
96+
$blocks = parse_blocks( $result );
97+
98+
$this->assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'], 'style.css should be stripped.' );
99+
$this->assertSame( '#ff0000', $blocks[0]['attrs']['style']['color']['text'], 'Other style properties should be preserved.' );
100+
}
101+
102+
/**
103+
* Tests that empty style object is cleaned up after stripping css.
104+
*
105+
* @covers ::wp_strip_custom_css_from_blocks
106+
* @ticket 64771
107+
*/
108+
public function test_cleans_up_empty_style_object() {
109+
$content = '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->';
110+
111+
$result = wp_unslash( wp_strip_custom_css_from_blocks( $content ) );
112+
$blocks = parse_blocks( $result );
113+
114+
$this->assertArrayNotHasKey( 'style', $blocks[0]['attrs'], 'Empty style object should be cleaned up after stripping css.' );
115+
}
116+
117+
/**
118+
* Tests that slashed content is handled correctly.
119+
*
120+
* @covers ::wp_strip_custom_css_from_blocks
121+
* @ticket 64771
122+
*/
123+
public function test_handles_slashed_content() {
124+
$content = '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->';
125+
$slashed = wp_slash( $content );
126+
127+
$result = wp_strip_custom_css_from_blocks( $slashed );
128+
$blocks = parse_blocks( wp_unslash( $result ) );
129+
130+
$this->assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'] ?? array(), 'style.css should be stripped even from slashed content.' );
131+
}
132+
133+
/**
134+
* Tests that the content_save_pre filter is added for a user without edit_css.
135+
*
136+
* @ticket 64771
137+
*
138+
* @covers ::wp_custom_css_kses_init
139+
* @covers ::wp_custom_css_kses_init_filters
140+
*/
141+
public function test_filter_added_for_user_without_edit_css() {
142+
$author_id = self::factory()->user->create( array( 'role' => 'author' ) );
143+
wp_set_current_user( $author_id );
144+
wp_custom_css_kses_init();
145+
146+
$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.' );
147+
$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.' );
148+
149+
wp_set_current_user( 0 );
150+
wp_custom_css_remove_filters();
151+
}
152+
153+
/**
154+
* Tests that the content_save_pre filter is not added for a user with edit_css.
155+
*
156+
* @ticket 64771
157+
*
158+
* @covers ::wp_custom_css_kses_init
159+
* @covers ::wp_custom_css_remove_filters
160+
*/
161+
public function test_filter_not_added_for_user_with_edit_css() {
162+
$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
163+
if ( is_multisite() ) {
164+
grant_super_admin( $admin_id );
165+
}
166+
wp_set_current_user( $admin_id );
167+
wp_custom_css_kses_init();
168+
169+
$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.' );
170+
$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.' );
171+
172+
if ( is_multisite() ) {
173+
revoke_super_admin( $admin_id );
174+
}
175+
wp_set_current_user( 0 );
176+
wp_custom_css_remove_filters();
177+
}
178+
179+
/**
180+
* Tests that switching to a user with edit_css removes the filter via the set_current_user action.
181+
*
182+
* wp_custom_css_kses_init() is hooked to set_current_user, so wp_set_current_user()
183+
* alone should update the filter state without a manual call.
184+
*
185+
* @ticket 64771
186+
*
187+
* @covers ::wp_custom_css_kses_init
188+
*/
189+
public function test_set_current_user_action_triggers_reinit() {
190+
$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
191+
$author_id = self::factory()->user->create( array( 'role' => 'author' ) );
192+
if ( is_multisite() ) {
193+
grant_super_admin( $admin_id );
194+
}
195+
196+
// Switching to a user without edit_css should add the filter via the set_current_user action.
197+
wp_set_current_user( $author_id );
198+
$this->assertNotFalse( has_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks' ), 'Filter should be active for user without edit_css.' );
199+
200+
// Switching to a user with edit_css should remove the filter via the set_current_user action.
201+
wp_set_current_user( $admin_id );
202+
$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.' );
203+
204+
if ( is_multisite() ) {
205+
revoke_super_admin( $admin_id );
206+
}
207+
wp_set_current_user( 0 );
208+
wp_custom_css_remove_filters();
209+
}
210+
211+
/**
212+
* Tests that the filter is enabled during import regardless of user capability.
213+
*
214+
* @ticket 64771
215+
*
216+
* @covers ::wp_custom_css_force_filtered_html_on_import_filter
217+
*/
218+
public function test_force_filtered_html_on_import_enables_filter_for_privileged_user() {
219+
$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
220+
if ( is_multisite() ) {
221+
grant_super_admin( $admin_id );
222+
}
223+
wp_set_current_user( $admin_id );
224+
wp_custom_css_kses_init();
225+
226+
$this->assertFalse( has_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks' ), 'Filter should not be active for admin before import.' );
227+
228+
apply_filters( 'force_filtered_html_on_import', true );
229+
230+
$this->assertNotFalse( has_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks' ), 'Filter should be enabled during import regardless of user capability.' );
231+
232+
if ( is_multisite() ) {
233+
revoke_super_admin( $admin_id );
234+
}
235+
wp_set_current_user( 0 );
236+
wp_custom_css_remove_filters();
237+
}
238+
}

0 commit comments

Comments
 (0)