Skip to content

Commit 5dd7a2b

Browse files
glendaviesnzramonjd
authored andcommitted
Block Supports: Strip custom CSS from blocks for users without edit_css capability
Add capability-gated CSS stripping so that when a user without `edit_css` saves a post, any `style.css` attributes are surgically removed from block comments using `WP_Block_Parser::next_token()`. Props TODO. See #64771.
1 parent bccb9c1 commit 5dd7a2b

File tree

2 files changed

+276
-0
lines changed

2 files changed

+276
-0
lines changed

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

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,153 @@ 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 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+
list( $token_type, , $attrs, $start_offset, $token_length ) = $next_token;
156+
157+
if ( 'no-more-tokens' === $token_type ) {
158+
break;
159+
}
160+
161+
$parser->offset = $start_offset + $token_length;
162+
163+
if ( 'block-opener' !== $token_type && 'void-block' !== $token_type ) {
164+
continue;
165+
}
166+
167+
if ( ! isset( $attrs['style']['css'] ) ) {
168+
continue;
169+
}
170+
171+
// Remove css and clean up empty style.
172+
unset( $attrs['style']['css'] );
173+
if ( empty( $attrs['style'] ) ) {
174+
unset( $attrs['style'] );
175+
}
176+
177+
// Locate the JSON portion within the token.
178+
$token_string = substr( $unslashed, $start_offset, $token_length );
179+
$json_rel_start = strcspn( $token_string, '{' );
180+
$json_rel_end = strrpos( $token_string, '}' );
181+
182+
$json_start = $start_offset + $json_rel_start;
183+
$json_length = $json_rel_end - $json_rel_start + 1;
184+
185+
// Re-encode attributes. If attrs is now empty, remove JSON and trailing space.
186+
if ( empty( $attrs ) ) {
187+
// Remove the trailing space after JSON.
188+
$replacements[] = array( $json_start, $json_length + 1, '' );
189+
} else {
190+
$replacements[] = array( $json_start, $json_length, serialize_block_attributes( $attrs ) );
191+
}
192+
}
193+
194+
if ( empty( $replacements ) ) {
195+
return $content;
196+
}
197+
198+
// Build the result by splicing replacements into the original string.
199+
$result = '';
200+
$was_at = 0;
201+
202+
foreach ( $replacements as $replacement ) {
203+
list( $offset, $length, $new_json ) = $replacement;
204+
$result .= substr( $unslashed, $was_at, $offset - $was_at ) . $new_json;
205+
$was_at = $offset + $length;
206+
}
207+
208+
if ( $was_at < $end ) {
209+
$result .= substr( $unslashed, $was_at );
210+
}
211+
212+
return addslashes( $result );
213+
}
214+
215+
/**
216+
* Adds the filters to strip custom CSS from block content on save.
217+
*
218+
* @since 7.0.0
219+
* @access private
220+
*/
221+
function wp_custom_css_kses_init_filters() {
222+
add_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks', 8 );
223+
add_filter( 'content_filtered_save_pre', 'wp_strip_custom_css_from_blocks', 8 );
224+
}
225+
226+
/**
227+
* Removes the filters that strip custom CSS from block content on save.
228+
*
229+
* @since 7.0.0
230+
* @access private
231+
*/
232+
function wp_custom_css_remove_filters() {
233+
remove_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks', 8 );
234+
remove_filter( 'content_filtered_save_pre', 'wp_strip_custom_css_from_blocks', 8 );
235+
}
236+
237+
/**
238+
* Registers the custom CSS content filters if the user does not have the edit_css capability.
239+
*
240+
* @since 7.0.0
241+
* @access private
242+
*/
243+
function wp_custom_css_kses_init() {
244+
wp_custom_css_remove_filters();
245+
if ( ! current_user_can( 'edit_css' ) ) {
246+
wp_custom_css_kses_init_filters();
247+
}
248+
}
249+
250+
/**
251+
* Initializes custom CSS content filters when imported data should be filtered.
252+
*
253+
* This filter is the last being executed on force_filtered_html_on_import.
254+
* If the input of the filter is true it means we are in an import situation and should
255+
* enable the custom CSS filters, independently of the user capabilities.
256+
*
257+
* @since 7.0.0
258+
* @access private
259+
*
260+
* @param mixed $arg Input argument of the filter.
261+
* @return mixed Input argument of the filter.
262+
*/
263+
function wp_custom_css_force_filtered_html_on_import_filter( $arg ) {
264+
if ( $arg ) {
265+
wp_custom_css_kses_init_filters();
266+
}
267+
return $arg;
268+
}
269+
270+
add_action( 'init', 'wp_custom_css_kses_init', 20 );
271+
add_action( 'set_current_user', 'wp_custom_css_kses_init' );
272+
add_filter( 'force_filtered_html_on_import', 'wp_custom_css_force_filtered_html_on_import_filter', 999 );
273+
127274
// Register the block support.
128275
WP_Block_Supports::get_instance()->register(
129276
'custom-css',
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
/**
4+
* @group block-supports
5+
*
6+
* @covers ::wp_strip_custom_css_from_blocks
7+
*/
8+
class Tests_Block_Supports_WpStripCustomCssFromBlocks extends WP_UnitTestCase {
9+
10+
/**
11+
* Tests that style.css is stripped from block attributes.
12+
*
13+
* @ticket 63
14+
*
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+
}
26+
27+
/**
28+
* Data provider.
29+
*
30+
* @return array
31+
*/
32+
public function data_strips_css_from_blocks() {
33+
return array(
34+
'single block' => array(
35+
'content' => '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->',
36+
'message' => 'style.css should be stripped from block attributes.',
37+
),
38+
'empty style object is cleaned up' => array(
39+
'content' => '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->',
40+
'message' => 'style.css should be stripped from block attributes.',
41+
),
42+
);
43+
}
44+
45+
/**
46+
* Tests that style.css is stripped from nested inner blocks.
47+
*
48+
* @ticket 63
49+
*/
50+
public function test_strips_css_from_inner_blocks() {
51+
$content = '<!-- wp:group --><div class="wp-block-group"><!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph --></div><!-- /wp:group -->';
52+
53+
$result = wp_unslash( wp_strip_custom_css_from_blocks( $content ) );
54+
$blocks = parse_blocks( $result );
55+
56+
$inner_block = $blocks[0]['innerBlocks'][0];
57+
$this->assertArrayNotHasKey( 'css', $inner_block['attrs']['style'] ?? array(), 'style.css should be stripped from inner block attributes.' );
58+
}
59+
60+
/**
61+
* Tests that content without blocks is returned unchanged.
62+
*
63+
* @ticket 63
64+
*/
65+
public function test_returns_non_block_content_unchanged() {
66+
$content = '<p>This is plain HTML content with no blocks.</p>';
67+
68+
$result = wp_strip_custom_css_from_blocks( $content );
69+
70+
$this->assertSame( $content, $result, 'Non-block content should be returned unchanged.' );
71+
}
72+
73+
/**
74+
* Tests that content without style.css attributes is returned unchanged.
75+
*
76+
* @ticket 63
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+
* @ticket 63
90+
*/
91+
public function test_preserves_other_style_properties() {
92+
$content = '<!-- wp:paragraph {"style":{"css":"color: red;","color":{"text":"#ff0000"}}} --><p>Hello</p><!-- /wp:paragraph -->';
93+
94+
$result = wp_unslash( wp_strip_custom_css_from_blocks( $content ) );
95+
$blocks = parse_blocks( $result );
96+
97+
$this->assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'], 'style.css should be stripped.' );
98+
$this->assertSame( '#ff0000', $blocks[0]['attrs']['style']['color']['text'], 'Other style properties should be preserved.' );
99+
}
100+
101+
/**
102+
* Tests that empty style object is cleaned up after stripping css.
103+
*
104+
* @ticket 63
105+
*/
106+
public function test_cleans_up_empty_style_object() {
107+
$content = '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->';
108+
109+
$result = wp_unslash( wp_strip_custom_css_from_blocks( $content ) );
110+
$blocks = parse_blocks( $result );
111+
112+
$this->assertArrayNotHasKey( 'style', $blocks[0]['attrs'], 'Empty style object should be cleaned up after stripping css.' );
113+
}
114+
115+
/**
116+
* Tests that slashed content is handled correctly.
117+
*
118+
* @ticket 63
119+
*/
120+
public function test_handles_slashed_content() {
121+
$content = '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->';
122+
$slashed = wp_slash( $content );
123+
124+
$result = wp_strip_custom_css_from_blocks( $slashed );
125+
$blocks = parse_blocks( wp_unslash( $result ) );
126+
127+
$this->assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'] ?? array(), 'style.css should be stripped even from slashed content.' );
128+
}
129+
}

0 commit comments

Comments
 (0)