Skip to content

Commit 02b2545

Browse files
glendaviesnzglendaviesnzaaronrobertshawramonjddmsnell
authored andcommitted
Block Editor: Strip per-block custom CSS on save for users without edit_css (#76650)
Co-authored-by: glendaviesnz <glendaviesnz@git.wordpress.org> Co-authored-by: aaronrobertshaw <aaronrobertshaw@git.wordpress.org> Co-authored-by: ramonjd <ramonopoly@git.wordpress.org> Co-authored-by: dmsnell <dmsnell@git.wordpress.org> Co-authored-by: sirreal <jonsurrell@git.wordpress.org>
1 parent aa9c7e4 commit 02b2545

4 files changed

Lines changed: 268 additions & 8 deletions

File tree

backport-changelog/7.0/11347.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
https://github.com/WordPress/wordpress-develop/pull/11347
2+
3+
* https://github.com/WordPress/gutenberg/pull/76650

lib/block-supports/custom-css.php

Lines changed: 136 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
/**
99
* Render the custom CSS stylesheet and add class name to block as required.
1010
*
11-
* @since 7.0.0
12-
*
1311
* @param array $parsed_block The parsed block.
1412
* @return array The same parsed block with custom CSS class name added if appropriate.
1513
*/
@@ -58,8 +56,6 @@ function gutenberg_render_custom_css_support_styles( $parsed_block ) {
5856

5957
/**
6058
* Enqueues the block custom CSS styles.
61-
*
62-
* @since 7.0.0
6359
*/
6460
function gutenberg_enqueue_block_custom_css() {
6561
wp_enqueue_style( 'wp-block-custom-css' );
@@ -71,8 +67,6 @@ function gutenberg_enqueue_block_custom_css() {
7167
* The class name is generated in `gutenberg_render_custom_css_support_styles`
7268
* and stored in block attributes. This filter adds it to the actual markup.
7369
*
74-
* @since 7.0.0
75-
*
7670
* @param string $block_content Rendered block content.
7771
* @param array $block Block object.
7872
* @return string Filtered block content.
@@ -124,6 +118,142 @@ function gutenberg_register_custom_css_support( $block_type ) {
124118
}
125119
}
126120

121+
/**
122+
* Strips `style.css` attributes from all blocks in post content.
123+
*
124+
* Uses WP_Block_Parser::next_token() to scan block tokens and surgically
125+
* replace only the attribute JSON that changed — no parse_blocks() +
126+
* serialize_blocks() round-trip needed.
127+
*
128+
* @param string $content Post content to filter, expected to be escaped with slashes.
129+
* @return string Filtered post content with block custom CSS removed.
130+
*/
131+
function gutenberg_strip_custom_css_from_blocks( $content ) {
132+
if ( ! has_blocks( $content ) ) {
133+
return $content;
134+
}
135+
136+
$unslashed = stripslashes( $content );
137+
138+
$parser = new WP_Block_Parser();
139+
$parser->document = $unslashed;
140+
$parser->offset = 0;
141+
$end = strlen( $unslashed );
142+
$replacements = array();
143+
144+
while ( $parser->offset < $end ) {
145+
$next_token = $parser->next_token();
146+
list( $token_type, , $attrs, $start_offset, $token_length ) = $next_token;
147+
148+
if ( 'no-more-tokens' === $token_type ) {
149+
break;
150+
}
151+
152+
$parser->offset = $start_offset + $token_length;
153+
154+
if ( 'block-opener' !== $token_type && 'void-block' !== $token_type ) {
155+
continue;
156+
}
157+
158+
if ( ! isset( $attrs['style']['css'] ) ) {
159+
continue;
160+
}
161+
162+
// Remove css and clean up empty style.
163+
unset( $attrs['style']['css'] );
164+
if ( empty( $attrs['style'] ) ) {
165+
unset( $attrs['style'] );
166+
}
167+
168+
// Locate the JSON portion within the token.
169+
$token_string = substr( $unslashed, $start_offset, $token_length );
170+
$json_rel_start = strcspn( $token_string, '{' );
171+
$json_rel_end = strrpos( $token_string, '}' );
172+
173+
$json_start = $start_offset + $json_rel_start;
174+
$json_length = $json_rel_end - $json_rel_start + 1;
175+
176+
// Re-encode attributes. If attrs is now empty, remove JSON and trailing space.
177+
if ( empty( $attrs ) ) {
178+
// Remove the trailing space after JSON: `{"style":{"css":"x"}} ` → ``
179+
$replacements[] = array( $json_start, $json_length + 1, '' );
180+
} else {
181+
$replacements[] = array( $json_start, $json_length, serialize_block_attributes( $attrs ) );
182+
}
183+
}
184+
185+
if ( empty( $replacements ) ) {
186+
return $content;
187+
}
188+
189+
// Build the result by splicing replacements into the original string.
190+
$result = '';
191+
$was_at = 0;
192+
193+
foreach ( $replacements as $replacement ) {
194+
list( $offset, $length, $new_json ) = $replacement;
195+
$result .= substr( $unslashed, $was_at, $offset - $was_at ) . $new_json;
196+
$was_at = $offset + $length;
197+
}
198+
199+
if ( $was_at < $end ) {
200+
$result .= substr( $unslashed, $was_at );
201+
}
202+
203+
return addslashes( $result );
204+
}
205+
206+
/**
207+
* Adds the filters to strip custom CSS from block content on save.
208+
* @access private
209+
*/
210+
function gutenberg_custom_css_kses_init_filters() {
211+
add_filter( 'content_save_pre', 'gutenberg_strip_custom_css_from_blocks', 8 );
212+
add_filter( 'content_filtered_save_pre', 'gutenberg_strip_custom_css_from_blocks', 8 );
213+
}
214+
215+
/**
216+
* Removes the filters that strip custom CSS from block content on save.
217+
* @access private
218+
*/
219+
function gutenberg_custom_css_remove_filters() {
220+
remove_filter( 'content_save_pre', 'gutenberg_strip_custom_css_from_blocks', 8 );
221+
remove_filter( 'content_filtered_save_pre', 'gutenberg_strip_custom_css_from_blocks', 8 );
222+
}
223+
224+
/**
225+
* Registers the custom CSS content filters if the user does not have the edit_css capability.
226+
* @access private
227+
*/
228+
function gutenberg_custom_css_kses_init() {
229+
gutenberg_custom_css_remove_filters();
230+
if ( ! current_user_can( 'edit_css' ) ) {
231+
gutenberg_custom_css_kses_init_filters();
232+
}
233+
}
234+
235+
/**
236+
* Initializes custom CSS content filters when imported data should be filtered.
237+
*
238+
* This filter is the last being executed on force_filtered_html_on_import.
239+
* If the input of the filter is true it means we are in an import situation and should
240+
* enable the custom CSS filters, independently of the user capabilities.
241+
* @access private
242+
*
243+
* @param mixed $arg Input argument of the filter.
244+
* @return mixed Input argument of the filter.
245+
*/
246+
function gutenberg_custom_css_force_filtered_html_on_import_filter( $arg ) {
247+
if ( $arg ) {
248+
gutenberg_custom_css_kses_init_filters();
249+
}
250+
return $arg;
251+
}
252+
253+
add_action( 'init', 'gutenberg_custom_css_kses_init', 20 );
254+
add_action( 'set_current_user', 'gutenberg_custom_css_kses_init' );
255+
add_filter( 'force_filtered_html_on_import', 'gutenberg_custom_css_force_filtered_html_on_import_filter', 999 );
256+
127257
// Register the block support.
128258
WP_Block_Supports::get_instance()->register(
129259
'custom-css',

packages/block-editor/src/hooks/custom-css.js

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
/**
22
* WordPress dependencies
33
*/
4-
import { useMemo } from '@wordpress/element';
5-
import { useSelect } from '@wordpress/data';
4+
import { useEffect, useMemo } from '@wordpress/element';
5+
import { useDispatch, useSelect } from '@wordpress/data';
66
import { useInstanceId } from '@wordpress/compose';
77
import { getBlockType, hasBlockSupport } from '@wordpress/blocks';
88
import { __, sprintf } from '@wordpress/i18n';
99
import { processCSSNesting } from '@wordpress/global-styles-engine';
1010
import { useBlockEditingMode } from '../components/block-editing-mode';
11+
import { store as noticesStore } from '@wordpress/notices';
1112

1213
/**
1314
* Internal dependencies
@@ -69,6 +70,8 @@ function CustomCSSControl( { blockName, setAttributes, style } ) {
6970
);
7071
}
7172

73+
const CUSTOM_CSS_WARNING_NOTICE_ID = 'custom-css-edit-warning';
74+
7275
function CustomCSSEdit( { clientId, name, setAttributes } ) {
7376
const { style, canEditCSS } = useSelect(
7477
( select ) => {
@@ -113,6 +116,31 @@ function useBlockProps( { style } ) {
113116
customCSS.trim().length > 0 &&
114117
validateCSS( customCSS );
115118

119+
const canEditCSS = useSelect(
120+
( select ) => select( blockEditorStore ).getSettings().canEditCSS,
121+
[]
122+
);
123+
124+
const { createWarningNotice } = useDispatch( noticesStore );
125+
126+
// Show a warning notice when the user lacks edit_css and a block has
127+
// custom CSS. The fixed notice ID ensures only one notice is shown
128+
// regardless of how many blocks have CSS.
129+
const hasCustomCSS = !! customCSS?.trim();
130+
useEffect( () => {
131+
if ( ! canEditCSS && hasCustomCSS ) {
132+
createWarningNotice(
133+
__(
134+
'This post contains blocks with custom CSS. You do not have permission to edit CSS. If you save this post, the custom CSS will be removed.'
135+
),
136+
{
137+
id: CUSTOM_CSS_WARNING_NOTICE_ID,
138+
isDismissible: true,
139+
}
140+
);
141+
}
142+
}, [ canEditCSS, hasCustomCSS, createWarningNotice ] );
143+
116144
const customCSSIdentifier = useInstanceId(
117145
CUSTOM_CSS_INSTANCE_REFERENCE,
118146
'wp-custom-css'

phpunit/block-supports/custom-css-test.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,4 +438,103 @@ public function test_custom_css_accepts_valid_css() {
438438

439439
$this->assertArrayHasKey( 'className', $result['attrs'], 'Block should have className added for valid CSS.' );
440440
}
441+
442+
/**
443+
* Tests that style.css is stripped from a single block.
444+
*
445+
* @covers ::gutenberg_strip_custom_css_from_blocks
446+
*/
447+
public function test_strip_custom_css_removes_css_from_block() {
448+
$content = '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->';
449+
450+
$result = wp_unslash( gutenberg_strip_custom_css_from_blocks( $content ) );
451+
$blocks = parse_blocks( $result );
452+
453+
$this->assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'] ?? array(), 'style.css should be stripped from block attributes.' );
454+
}
455+
456+
/**
457+
* Tests that style.css is stripped from nested inner blocks.
458+
*
459+
* @covers ::gutenberg_strip_custom_css_from_blocks
460+
*/
461+
public function test_strip_custom_css_removes_css_from_inner_blocks() {
462+
$content = '<!-- wp:group --><div class="wp-block-group"><!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph --></div><!-- /wp:group -->';
463+
464+
$result = wp_unslash( gutenberg_strip_custom_css_from_blocks( $content ) );
465+
$blocks = parse_blocks( $result );
466+
467+
$inner_block = $blocks[0]['innerBlocks'][0];
468+
$this->assertArrayNotHasKey( 'css', $inner_block['attrs']['style'] ?? array(), 'style.css should be stripped from inner block attributes.' );
469+
}
470+
471+
/**
472+
* Tests that content without blocks is returned unchanged.
473+
*
474+
* @covers ::gutenberg_strip_custom_css_from_blocks
475+
*/
476+
public function test_strip_custom_css_returns_non_block_content_unchanged() {
477+
$content = '<p>This is plain HTML content with no blocks.</p>';
478+
479+
$result = gutenberg_strip_custom_css_from_blocks( $content );
480+
481+
$this->assertSame( $content, $result, 'Non-block content should be returned unchanged.' );
482+
}
483+
484+
/**
485+
* Tests that content without style.css attributes is returned unchanged.
486+
*
487+
* @covers ::gutenberg_strip_custom_css_from_blocks
488+
*/
489+
public function test_strip_custom_css_returns_unchanged_when_no_css_attributes() {
490+
$content = '<!-- wp:paragraph {"style":{"color":{"text":"#ff0000"}}} --><p class="has-text-color" style="color:#ff0000">Hello</p><!-- /wp:paragraph -->';
491+
492+
$result = gutenberg_strip_custom_css_from_blocks( $content );
493+
494+
$this->assertSame( $content, $result, 'Content without style.css attributes should be returned unchanged.' );
495+
}
496+
497+
/**
498+
* Tests that other style properties are preserved when css is stripped.
499+
*
500+
* @covers ::gutenberg_strip_custom_css_from_blocks
501+
*/
502+
public function test_strip_custom_css_preserves_other_style_properties() {
503+
$content = '<!-- wp:paragraph {"style":{"css":"color: red;","color":{"text":"#ff0000"}}} --><p>Hello</p><!-- /wp:paragraph -->';
504+
505+
$result = wp_unslash( gutenberg_strip_custom_css_from_blocks( $content ) );
506+
$blocks = parse_blocks( $result );
507+
508+
$this->assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'], 'style.css should be stripped.' );
509+
$this->assertSame( '#ff0000', $blocks[0]['attrs']['style']['color']['text'], 'Other style properties should be preserved.' );
510+
}
511+
512+
/**
513+
* Tests that empty style object is cleaned up after stripping css.
514+
*
515+
* @covers ::gutenberg_strip_custom_css_from_blocks
516+
*/
517+
public function test_strip_custom_css_cleans_up_empty_style_object() {
518+
$content = '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->';
519+
520+
$result = wp_unslash( gutenberg_strip_custom_css_from_blocks( $content ) );
521+
$blocks = parse_blocks( $result );
522+
523+
$this->assertArrayNotHasKey( 'style', $blocks[0]['attrs'], 'Empty style object should be cleaned up after stripping css.' );
524+
}
525+
526+
/**
527+
* Tests that slashed content is handled correctly.
528+
*
529+
* @covers ::gutenberg_strip_custom_css_from_blocks
530+
*/
531+
public function test_strip_custom_css_handles_slashed_content() {
532+
$content = '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->';
533+
$slashed = wp_slash( $content );
534+
535+
$result = gutenberg_strip_custom_css_from_blocks( $slashed );
536+
$blocks = parse_blocks( wp_unslash( $result ) );
537+
538+
$this->assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'] ?? array(), 'style.css should be stripped even from slashed content.' );
539+
}
441540
}

0 commit comments

Comments
 (0)