Skip to content

Fix problem with encoding of css entities when post with block level custom css is edited by user without unfiltered_html#11104

Open
glendaviesnz wants to merge 2 commits intoWordPress:trunkfrom
glendaviesnz:fix/block-custom-css-bug
Open

Fix problem with encoding of css entities when post with block level custom css is edited by user without unfiltered_html#11104
glendaviesnz wants to merge 2 commits intoWordPress:trunkfrom
glendaviesnz:fix/block-custom-css-bug

Conversation

@glendaviesnz
Copy link

@glendaviesnz glendaviesnz commented Mar 1, 2026

Trac ticket: https://core.trac.wordpress.org/ticket/64771

Summary

WordPress/gutenberg#73959 introduced block-level custom CSS. Everything works as expected unless a user without unfiltered_html edits a page/post with block-level custom CSS that includes nested selectors, eg.

color: green;
& p {color: blue}

This PR fixes double-encoding of HTML entities in per-block custom CSS (attrs.style.css) when a user without the unfiltered_html capability saves a post that includes block-level custom CSS with nested selectors.

The problem

When a user without unfiltered_html (e.g. an Author) saves a block with custom CSS containing & (CSS nesting selector) or > (child combinator), the filter_block_content() pipeline corrupts these characters through double-encoding:

  1. parse_blocks()json_decode() decodes \u0026&
  2. filter_block_kses_value()wp_kses() treats the CSS string as HTML and encodes &&, >>
  3. serialize_block_attributes()json_encode() encodes the & in &\u0026amp;

The result is \u0026amp; in post_content instead of the original \u0026. On the next editor load, json_decode() produces the literal string & instead of &, so the CSS textarea displays corrupted values like & and >. Each subsequent save compounds the corruption further.

The fix

After KSES has run on block attributes (and stripped any dangerous HTML tags), decode the specific named entities it introduced in the style.css attribute. HTML entities are invalid in CSS, so KSES should not have introduced them.

This PR adds:

  1. undo_block_custom_css_kses_entities() — a new function that reverses only the 4 specific named entities that wp_kses() may introduce (&, >, ", '). This is intentionally narrower than wp_specialchars_decode() to avoid decoding numeric/hex references that KSES may have intentionally preserved.

  2. A call in filter_block_kses() — after filter_block_kses_value() has processed all attributes, if attrs.style.css exists, it is passed through the decode function before the block is returned for serialization.

Why this is safe

  • KSES runs first — any actual HTML tags in the CSS value are already stripped before we decode entities
  • Only 4 specific named entities are decoded — no numeric/hex character references (e.g. <) are affected
  • &lt; is intentionally excluded — KSES strips bare < entirely rather than encoding it, so &lt; in the output would indicate it was already present in the input
  • Scoped to attrs.style.css only — other block attributes remain entity-encoded as expected

Test steps

Setup

  1. Create a test user with the Author role (no unfiltered_html capability)
  2. Log in as that Author

Test 1: CSS nesting selector (&)

  1. Create a new post as an admin user
  2. Add a Group block with a nested Paragraph block
  3. Open the block's Advanced panel → Additional CSS textarea
  4. Enter: color: blue; & p { color: red; }
  5. Save the post
  6. Log in as a user without unfiltered_html, eg. author and edit the paragraph, eg. make part of string italic. Note you will not see the custom CSS input box when logged in as this user. Just edit the existing paragraph in the post content and save.
  7. Save again and then log back in as an admin user
  8. Open the Additional CSS textarea again
  9. Expected: CSS shows color: blue; & .child { color: red; } (unchanged)
  10. Before fix: CSS shows color: blue; &amp; .child { color: red; }

Test 2: Child combinator (>)

  1. Follow the same flow as above with admin and author users, but this time in the same or a new block, enter CSS: & > p { margin: 0; }
  2. Save and reload
  3. Expected: CSS shows & > p { margin: 0; } (unchanged)
  4. Before fix: CSS shows &amp; &gt; p { margin: 0; }

Test 3: Idempotent saves

  1. With the CSS from Test 1 or 2, save the post 3-4 times, with admin and author users reloading between each save
  2. Expected: The CSS remains identical after every save — no progressive corruption

Test 4: Frontend rendering

  1. View the post on the frontend
  2. Expected: The custom CSS is applied correctly (e.g. child elements styled as specified)

Test 5: Non-CSS attributes are unaffected

  1. As the Author, add a Paragraph block
  2. In the Advanced panel, set the Additional CSS class(es) to something containing & (e.g. foo&bar)
  3. Save and reload
  4. Expected: The className attribute is still processed by KSES as before (entity-encoded) — only attrs.style.css is decoded

This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

…custom css is edited by user without unfiltered_html
@glendaviesnz glendaviesnz self-assigned this Mar 1, 2026
@github-actions
Copy link

github-actions bot commented Mar 1, 2026

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props glendaviesnz, ramonopoly, jonsurrell, dmsnell.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions
Copy link

github-actions bot commented Mar 1, 2026

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@glendaviesnz
Copy link
Author

FYI - I will add tests for this once there is confirmation that this is the correct approach for fixing this bug. There may be a better solution. If there is feel free to close this PR and open an alternative.


return str_replace(
array( '&amp;', '&gt;', '&quot;', '&#039;' ),
array( '&', '>', '"', "'" ),
Copy link
Author

@glendaviesnz glendaviesnz Mar 1, 2026

Choose a reason for hiding this comment

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

We will not doubt need to account for other values here, but we can work out exactly what needs to be covered once there is some agreement on the best way to solve this problem - I imagine there will be a smarter solution - so didn't spend too much time finessing this one.

@@ -2077,6 +2077,17 @@ function _filter_block_content_callback( $matches ) {
function filter_block_kses( $block, $allowed_html, $allowed_protocols = array() ) {
$block['attrs'] = filter_block_kses_value( $block['attrs'], $allowed_html, $allowed_protocols, $block );
Copy link
Member

@ramonjd ramonjd Mar 2, 2026

Choose a reason for hiding this comment

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

Thanks for getting up a fix!

I was wondering: what is the important sanitization step for block CSS attributes?

wp_kses() treats the CSS string as HTML. But should it?

I'm wondering if an alternative solution would be to target the css attribute and run it through wp_strip_all_tags rather than wp_kses.

Or running through something similar (or reuse this same validation in a helper) that @sirreal and @dmsnell worked on in WP_REST_Global_Styles_Controller::validate_custom_css() for https://core.trac.wordpress.org/ticket/64418

There, for users without unfiltered_html, & and > in block custom CSS were being double-encoded by KSES + JSON, so the CSS broke.

(Sorry Jon and Dennis - you've become my default go-to brains trust for this stuff 😄 )

@glendaviesnz
Copy link
Author

glendaviesnz commented Mar 2, 2026

Related: https://core.trac.wordpress.org/changeset/61486 / #10641 fixed the same class of KSES-mangling issue for Global Styles custom CSS by pre-escaping the JSON with JSON_HEX_TAG | JSON_HEX_AMP. This PR addresses the same problem for per-block custom CSS (attrs.style.css), which goes through the separate filter_block_kses() pipeline. I don't think the same pre-escaping approach can work in this case, as parse_blocks() calls json_decode() on the entire attributes object, which converts \u0026 back to & before KSES runs - but I do not know a lot about these flows, and don't have time to look closer as currenlty travelling - so could be completely wrong about this.

@sirreal
Copy link
Member

sirreal commented Mar 2, 2026

I had some trouble reproducing the issue.

In order to test this, I had to enable a recent version of the Gutenberg plugin. The individual block CSS feature is not yet available in Core yet, is it?

When I used an author role, I don't see the additional CSS panel for blocks. When I used an editor role, I was unable to reproduce the issue because it seems to have unfiltered_html capability already.

Am I doing something wrong in the reproduction steps?


Some thoughts based on the issue:

  • KSES is unsuitable for processing data that is not HTML. This type of issue appears again and again.
  • The KSES filters were added in r46896 / 7c38cf1. That seems to be a security fix with limited public information.
  • I wish we could avoid applying KSES (HTML) filtering everywhere, but that seems unlikely at this time.
  • I see "double-encoding" mentioned a few times. That doesn't seem accurate given the description. CSS text that will be used in rawtext STYLE (where HTML character references like &amp; are not used) has had HTML character reference escaping applied to it. &amp; appears in the CSS text because the character references will never be decoded in this context. It seems more accurate to talk about "mis-encoded" or even just "mangled." This is akin to applying any other unsuitable escaping mechanism.

I was very happy with the solution in r61486. Post content exclusively contained JSON which has some flexibility in escaping. JSON can be made to be plain HTML text by escaping HTML syntax characters (<>&). KSES ignores this. This approach escapes data before KSES can mangle it.

This PR tries to recover after KSES has mangled the data. That seems inherently more risky. It should be possible to decode HTML character references (like done here) but what if KSES starts to remove things that look like tags? What if I'd like to use content: '<data> here';? (KSES will likely strip <data> from this).

Another option is to protect the data before KSES can mangle it by encoding it ourselves in an HTML-text safe way. A few quick options come to mind:

Of course, before using the value it will need to be decoded appropriately. Either of these seem likely to prevent the issue by ensuring HTML syntax <>& are not present, so KSES should not take any action.

@glendaviesnz
Copy link
Author

I had some trouble reproducing the issue.
In order to test this, I had to enable a recent version of the Gutenberg plugin. The individual block CSS feature is not yet available in Core yet, is it?
When I used an author role, I don't see the additional CSS panel for blocks. When I used an editor role, I was unable to reproduce the issue because it seems to have unfiltered_html capability already.
Am I doing something wrong in the reproduction steps?

@sirreal when logged in as the author user you do not need to see the custom CSS input box, you just need to edit the post content with the existing custom CSS in place that was added when you created the post as the admin user. This bug only occurs if a user without unfiltered_html edits a post that had block level customCSS add by a higher level user.

@ramonjd
Copy link
Member

ramonjd commented Mar 3, 2026

A few quick options come to mind:

Thanks a lot @sirreal, this is great.

Could "don’t run KSES on block attribute attrs.style.css" be another option?

Above, I was thinking of an allowlist of “non-HTML” attribute paths that are not HTML, e.g. ['css'], and in filter_block_kses_value(), when the current path is in that list, use some other sanitizer, e.g. wp_strip_all_tags or a variant of it

If there are hidden gotchas there...

protect the data before KSES can mangle it by encoding it ourselves in an HTML-text safe way

Maybe @glendaviesnz can answer this: let's say we encode in filter_block_kses (and decode when output in custom-css.php), do we need to worry about backwards compat at all? For example, for folks that have already used this feature in the plugin or elsewhere, would we have to infer “is this encoded or plain?”

@sirreal
Copy link
Member

sirreal commented Mar 3, 2026

when logged in as the author user you… need to edit the post content with the existing custom CSS in place

Got it, that worked. I did have to change the post author so that the author role user could edit the post.

Could "don’t run KSES on block attribute attrs.style.css" be another option?

That's a way to prevent this issue. The problem is that exceptions like that often create vulnerabilities. If a bad actor knows attrs.style.css will not be sanitized, they can often find a way to abuse it.

@dmsnell
Copy link
Member

dmsnell commented Mar 3, 2026

would we have to infer “is this encoded or plain?”

this is going to be a dead-end, because it’s largely not possible to do that. we can build in signals into the storage to communicate it though. for instance, prefix a base64-encoded string.

or in that same vein but better, store the attribute as a data URI which says explicitly what the content is.

{
	"style": {
		"css": "data:text/css;base64,eyBjb2xvcjogcmVkOyB9"
	}	
}

for the sake of transparency we can always escape the CSS from any characters that would be “dangerous,” but I think we’ve seen a number of cases where this has gone wrong because downstream code likes to unescape and re-escape, which ends up eliminating the escaping we intentionally applied.

wp_strip_all_tags

wp_strip_all_tags() is never going to be appropriate for CSS, but CSS should still go through some process like KSES, which is what functions like safecss_filter_attr() are for. there are rules applied to things like URLs inside of CSS declarations which WordPress will want to apply.


the $context parameter of filter_block_kses_value() offers a potential place to raise the bar on CSS handling. if we had a sentinel value indicating that the attribute is supposed to be CSS we could apply more appropriate sanitization, but we would want to make sure we don’t make it easy for people to set that context from user-supplied inputs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants