Skip to content
Merged
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
3 changes: 3 additions & 0 deletions backport-changelog/7.0/11347.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
https://github.com/WordPress/wordpress-develop/pull/11347

* https://github.com/WordPress/gutenberg/pull/76650
142 changes: 136 additions & 6 deletions lib/block-supports/custom-css.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
/**
* Render the custom CSS stylesheet and add class name to block as required.
*
* @since 7.0.0
*
* @param array $parsed_block The parsed block.
* @return array The same parsed block with custom CSS class name added if appropriate.
*/
Expand Down Expand Up @@ -58,8 +56,6 @@ function gutenberg_render_custom_css_support_styles( $parsed_block ) {

/**
* Enqueues the block custom CSS styles.
*
* @since 7.0.0
*/
function gutenberg_enqueue_block_custom_css() {
wp_enqueue_style( 'wp-block-custom-css' );
Expand All @@ -71,8 +67,6 @@ function gutenberg_enqueue_block_custom_css() {
* The class name is generated in `gutenberg_render_custom_css_support_styles`
* and stored in block attributes. This filter adds it to the actual markup.
*
* @since 7.0.0
*
* @param string $block_content Rendered block content.
* @param array $block Block object.
* @return string Filtered block content.
Expand Down Expand Up @@ -136,6 +130,142 @@ function gutenberg_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.
*
* @param string $content Post content to filter, expected to be escaped with slashes.
* @return string Filtered post content with block custom CSS removed.
*/
function gutenberg_strip_custom_css_from_blocks( $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.

What are your thoughts on the migration path to Core here? I hesitate to say it out loud but filter_block_kses_value seems like candidate. Anyway, if and when it becomes clear, it might be worth noting them for the backport.

Copy link
Copy Markdown
Contributor Author

@glendaviesnz glendaviesnz Mar 19, 2026

Choose a reason for hiding this comment

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

Good question. filter_block_kses_value is an interesting candidate — it already walks block attributes during KSES and could potentially strip style.css inline rather than needing the separate content filter approach. The tricky part is that filter_block_kses_value operates at the attribute level during KSES processing, while this approach operates on the full serialized content, and probably more importantly it was already noted on the abandoned fix in core that this is not a KSES issue - mixing the CSS fix back into KSES at this point seems to muddy that water again.

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.

Yeah, it's tricky 🤔

The question is going to come up at some point though: "Where would this all live in Core?" As a set of filters perhaps?

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: `{"style":{"css":"x"}} ` → ``
$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.
* @access private
*/
function gutenberg_custom_css_kses_init_filters() {
add_filter( 'content_save_pre', 'gutenberg_strip_custom_css_from_blocks', 8 );
add_filter( 'content_filtered_save_pre', 'gutenberg_strip_custom_css_from_blocks', 8 );
Comment on lines +223 to +224
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.

wp_insert_post_data looks like it comes bundled with post_content and post_content_filtered in the $data arg.

It runs after KSES, but I guess that doesn't matter if we're stripping it.

And I think you wouldn't need the init/set_current_user dance either because wp_insert_post_data fires inside wp_insert_post() at the moment of save, and current_user_can('edit_css')` is evaluated in the hook callback against whoever is actually saving.

I could be missing something obvious.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's a fair point — wp_insert_post_data would simplify things since the capability check happens at save time against the actual user. The main reason for mirroring the KSES pattern (content_save_pre + init/set_current_user) was to stay consistent with how Core handles similar content filtering, which makes the eventual backport more straightforward. The content_save_pre / content_filtered_save_pre hooks are the same ones KSES uses, so the stripping runs at the same stage in the save pipeline. wp_insert_post_data runs later and bundles both fields in $data, which could work but would diverge from the established pattern. Open to changing it if you feel the simplicity outweighs the consistency argument though.

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 don't have a strong opinion, and thanks for confirming.

As noted above I'm just thinking about how this would look in Core, and where the processing would take place.

I guess it's contained to the custom-css.php file for now, and, as you say, it could follow the footnotes precedent:

https://github.com/wordpress/wordpress-develop/blob/trunk/src/wp-includes/blocks.php#L3107

}

/**
* Removes the filters that strip custom CSS from block content on save.
* @access private
*/
function gutenberg_custom_css_remove_filters() {
remove_filter( 'content_save_pre', 'gutenberg_strip_custom_css_from_blocks', 8 );
remove_filter( 'content_filtered_save_pre', 'gutenberg_strip_custom_css_from_blocks', 8 );
}

/**
* Registers the custom CSS content filters if the user does not have the edit_css capability.
* @access private
*/
function gutenberg_custom_css_kses_init() {
gutenberg_custom_css_remove_filters();
if ( ! current_user_can( 'edit_css' ) ) {
Comment thread
ramonjd marked this conversation as resolved.
gutenberg_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.
* @access private
*
* @param mixed $arg Input argument of the filter.
* @return mixed Input argument of the filter.
*/
function gutenberg_custom_css_force_filtered_html_on_import_filter( $arg ) {
if ( $arg ) {
gutenberg_custom_css_kses_init_filters();
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.

What is this doing exactly? Would it strip CSS for admins during import as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, this is the import safety net — again mirroring what Core does for KSES. When force_filtered_html_on_import is true (e.g. during a WXR import), it unconditionally enables the CSS stripping filters regardless of the current user's capabilities. So even an admin importing content would have custom CSS stripped. The rationale is the same as KSES during import: you can't trust the content source, so filter it regardless of who is doing the import.

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.

Got it, thanks for the explainer

}
return $arg;
}

add_action( 'init', 'gutenberg_custom_css_kses_init', 20 );
add_action( 'set_current_user', 'gutenberg_custom_css_kses_init' );
Comment on lines +265 to +266
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.

Just so I understand, these checks remove/add the hooks for different scenarios, e.g., page requests and REST?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes exactly. This mirrors the pattern Core uses for KSES in kses_init() / kses_init_filters(). The init hook covers the normal page-load/REST request path, and set_current_user covers cases where the current user changes after init (e.g. application passwords on REST, or XML-RPC authentication). The remove-then-conditionally-add pattern in gutenberg_custom_css_kses_init() ensures the filters are always in sync with whoever the current user actually is.

add_filter( 'force_filtered_html_on_import', 'gutenberg_custom_css_force_filtered_html_on_import_filter', 999 );

// Register the block support.
WP_Block_Supports::get_instance()->register(
'custom-css',
Expand Down
32 changes: 30 additions & 2 deletions packages/block-editor/src/hooks/custom-css.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
/**
* WordPress dependencies
*/
import { useMemo } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { useEffect, useMemo } from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import { useInstanceId } from '@wordpress/compose';
import { getBlockType, hasBlockSupport } from '@wordpress/blocks';
import { __, sprintf } from '@wordpress/i18n';
import { processCSSNesting } from '@wordpress/global-styles-engine';
import { useBlockEditingMode } from '../components/block-editing-mode';
import { store as noticesStore } from '@wordpress/notices';

/**
* Internal dependencies
Expand Down Expand Up @@ -69,6 +70,8 @@ function CustomCSSControl( { blockName, setAttributes, style } ) {
);
}

const CUSTOM_CSS_WARNING_NOTICE_ID = 'custom-css-edit-warning';

function CustomCSSEdit( { clientId, name, setAttributes } ) {
const { style, canEditCSS } = useSelect(
( select ) => {
Expand Down Expand Up @@ -113,6 +116,31 @@ function useBlockProps( { style } ) {
customCSS.trim().length > 0 &&
validateCSS( customCSS );

const canEditCSS = useSelect(
( select ) => select( blockEditorStore ).getSettings().canEditCSS,
[]
);

const { createWarningNotice } = useDispatch( noticesStore );

// Show a warning notice when the user lacks edit_css and a block has
// custom CSS. The fixed notice ID ensures only one notice is shown
// regardless of how many blocks have CSS.
const hasCustomCSS = !! customCSS?.trim();
useEffect( () => {
if ( ! canEditCSS && hasCustomCSS ) {
createWarningNotice(
__(
'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.'
),
{
id: CUSTOM_CSS_WARNING_NOTICE_ID,
isDismissible: true,
}
);
}
}, [ canEditCSS, hasCustomCSS, createWarningNotice ] );

const customCSSIdentifier = useInstanceId(
CUSTOM_CSS_INSTANCE_REFERENCE,
'wp-custom-css'
Expand Down
99 changes: 99 additions & 0 deletions phpunit/block-supports/custom-css-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -438,4 +438,103 @@ public function test_custom_css_accepts_valid_css() {

$this->assertArrayHasKey( 'className', $result['attrs'], 'Block should have className added for valid CSS.' );
}

/**
* Tests that style.css is stripped from a single block.
*
* @covers ::gutenberg_strip_custom_css_from_blocks
*/
public function test_strip_custom_css_removes_css_from_block() {
$content = '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->';

$result = wp_unslash( gutenberg_strip_custom_css_from_blocks( $content ) );
$blocks = parse_blocks( $result );

$this->assertArrayNotHasKey( 'css', $blocks[0]['attrs']['style'] ?? array(), 'style.css should be stripped from block attributes.' );
}

/**
* Tests that style.css is stripped from nested inner blocks.
*
* @covers ::gutenberg_strip_custom_css_from_blocks
*/
public function test_strip_custom_css_removes_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( gutenberg_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 ::gutenberg_strip_custom_css_from_blocks
*/
public function test_strip_custom_css_returns_non_block_content_unchanged() {
$content = '<p>This is plain HTML content with no blocks.</p>';

$result = gutenberg_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 ::gutenberg_strip_custom_css_from_blocks
*/
public function test_strip_custom_css_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 = gutenberg_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 ::gutenberg_strip_custom_css_from_blocks
*/
public function test_strip_custom_css_preserves_other_style_properties() {
$content = '<!-- wp:paragraph {"style":{"css":"color: red;","color":{"text":"#ff0000"}}} --><p>Hello</p><!-- /wp:paragraph -->';

$result = wp_unslash( gutenberg_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 ::gutenberg_strip_custom_css_from_blocks
*/
public function test_strip_custom_css_cleans_up_empty_style_object() {
$content = '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->';

$result = wp_unslash( gutenberg_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 ::gutenberg_strip_custom_css_from_blocks
*/
public function test_strip_custom_css_handles_slashed_content() {
$content = '<!-- wp:paragraph {"style":{"css":"color: red;"}} --><p>Hello</p><!-- /wp:paragraph -->';
$slashed = wp_slash( $content );

$result = gutenberg_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.' );
}
}
Loading