Make WordPress Core

Opened 5 days ago

Last modified 34 hours ago

#65270 new defect (bug)

wp_kses() corrupts valid CSS background-image: url(...) declarations into style=")" 7.0-RC4

Reported by: nextendweb's profile nextendweb Owned by:
Milestone: 7.0.1 Priority: normal
Severity: normal Version: 7.0
Component: Formatting Keywords: has-patch needs-unit-tests
Focuses: Cc:

Description

wp_kses() appears to incorrectly sanitize valid CSS background-image: url(...) declarations, resulting in malformed output instead of preserving the declaration or removing it entirely.

The issue reproduces with both literal quotes and HTML entity encoded quotes inside url(...).

Steps to Reproduce

$allowed = [
  'div' => [
    'style' => true,
  ],
];

echo wp_kses( '<div style="background-image: url(\'https://localhost/image.jpg\');"></div>', $allowed );

echo wp_kses( '<div style="background-image: url(&quot;https://localhost/image.jpg&quot;);"></div>', $allowed );

Actual Results

Both examples produce:

<div style=")"></div>

Expected Results

Expected output should either preserve the valid CSS:

<div style="background-image: url('https://localhost/image.jpg')"></div>

The sanitizer should not emit malformed residual CSS such as:

style=")"

Regression Notes

This appears to be a regression from WordPress 6.9.4 for single-quoted CSS url(...) values.

In WordPress 6.9.4, this worked as expected:

wp_kses(
    '<div style="background-image: url(\'https://localhost/image.jpg\');"></div>',
    [
        'div' => [
            'style' => true,
        ],
    ]
);

In WordPress 7.0-RC4, the same input returns:

<div style=")"></div>

The &quot; variant appears to produce the same broken result in both WordPress 6.9.4 and 7.0-RC4, so that part should not be described as a regression.

The regression scope appears to be valid CSS using single quotes inside url(...), which worked in 6.9.4 and now produces malformed residual output in 7.0-RC4.

Additional Notes

  • style => true is the expected way to allow the style attribute in wp_kses().
  • https is an allowed protocol by default.
  • The issue reproduces regardless of whether quotes inside url(...) are literal or entity encoded (&quot;).
  • This appears related to CSS sanitization in safecss_filter_attr() rather than HTML attribute allowlisting itself.
  • Potential regression in recent KSES/CSS parsing changes.

Change History (9)

#1 @nextendweb
5 days ago

Another example with & which also breaks

<?php
echo wp_kses('<div style="background-image: url(https://localhost/image.jpg?a=1&b=2);"></div>', [
    'div' => [
        'style' => ['background-image']
    ]
]);

Related Source Code

wp_kses_hair()

The issue appears to begin in wp_kses_hair() when the parsed attribute value is reconstructed and syntax characters are entity-encoded:

function wp_kses_hair( $attr, $allowed_protocols ) {
$attributes = array();
$uris       = wp_kses_uri_attributes();

$processor = new WP_HTML_Tag_Processor( "<wp {$attr}>" );
$processor->next_token();

$attribute_names = $processor->get_attribute_names_with_prefix( '' );
if ( null === $attribute_names || 0 === count( $attribute_names ) ) {
	return $attributes;
}

$syntax_characters = array(
	'&' => '&amp;',
	'<' => '&lt;',
	'>' => '&gt;',
	"'" => '&apos;',
	'"' => '&quot;',
);

foreach ( $attribute_names as $name ) {
	$value   = $processor->get_attribute( $name );
	$is_bool = true === $value;
	if ( is_string( $value ) && in_array( $name, $uris, true ) ) {
		$value = wp_kses_bad_protocol( $value, $allowed_protocols );
	}

	// Reconstruct and normalize the attribute value.
	$recoded = $is_bool ? '' : strtr( $value, $syntax_characters );
	$whole   = $is_bool ? $name : "{$name}=\"{$recoded}\"";

	$attributes[ $name ] = array(
		'name'  => $name,
		'value' => $recoded,
		'whole' => $whole,
		'vless' => $is_bool ? 'y' : 'n',
	);
}

return $attributes;

}

For this input:

<div style="background-image: url('https://localhost/image.jpg');"></div>

wp_kses_hair() returns the style value as:

background-image: url(&apos;https://localhost/image.jpg&apos;);

wp_kses_attr_check()

That encoded value is then passed into safecss_filter_attr():

if ( 'style' === $name_low ) {
$new_value = safecss_filter_attr( $value );
}

Debug output:

string(63) "background-image: url(&apos;https://localhost/image.jpg&apos;);"
string(1) ")"

safecss_filter_attr()

The malformed result appears to happen when safecss_filter_attr() splits declarations with explode( ';', ... ):

function safecss_filter_attr( $css, $deprecated = '' ) {
if ( ! empty( $deprecated ) ) {
_deprecated_argument( **FUNCTION**, '2.8.1' ); // Never implemented.
}

```
$css = wp_kses_no_null( $css );
$css = str_replace( array( "\n", "\r", "\t" ), '', $css );

$allowed_protocols = wp_allowed_protocols();

$css_array = explode( ';', trim( $css ) );
```

Because &apos; contains a semicolon, the CSS is split incorrectly:

background-image: url('https://localhost/image.jpg');

becomes roughly:

background-image: url(&apos
[https://localhost/image.jpg&apos](https://localhost/image.jpg&apos)
)

The last fragment survives sanitization and produces:

<div style=")"></div>

Possible Solutions

Solution 1: Preserve raw quote characters in wp_kses_hair()

Avoid storing the entity-encoded value in the internal value field returned by wp_kses_hair().

Potential adjustment:

$attributes[ $name ] = array(
'name'  => $name,
'value' => $is_bool ? '' : $value,
'whole' => $whole,
'vless' => $is_bool ? 'y' : 'n',
);

This keeps value as:

background-image: url('https://localhost/image.jpg');

while whole can remain safely escaped for HTML reconstruction.

Pros:

  • Keeps CSS sanitization operating on raw CSS values.
  • Avoids mixing HTML entity encoding with CSS parsing.
  • Fixes the WordPress 7.0 regression for valid single-quoted url(...) values.

Cons:

  • Does not fix existing failures involving already entity-encoded CSS values such as &quot;.

Solution 2: Decode HTML entities before CSS declaration parsing

Decode HTML entities in safecss_filter_attr() before splitting CSS declarations:

$css = html_entity_decode( $css, ENT_QUOTES | ENT_HTML5,get_option( 'blog_charset' ) );

before:

$css_array = explode( ';', trim( $css ) );

This converts:

url('https://localhost/image.jpg')

back into:

url('https://localhost/image.jpg')

before explode( ';', ... ) runs.

Pros:

  • Fixes the WordPress 7.0 regression.
  • Also fixes pre-existing failures involving entity-encoded CSS values such as &quot;.
  • Minimal patch with low implementation complexity.

Cons:

  • Changes parsing input semantics globally within safecss_filter_attr().
  • Decodes all HTML entities before CSS parsing, which could theoretically affect obscure edge cases.

Solution 3: Decode HTML entities before CSS declaration parsing and then enconde again

<?php
function safecss_filter_attr( $css, $deprecated = '' ) {

...

    $css        = strtr( $css, array(
        '&amp;' => '&',
        '&lt;' => '<',
        '&gt;' => '>',
        '&apos;' => "'",
        '&quot;' => '',
    ) );

    $css_array = explode( ';', trim( $css ) );

...

    return strtr( $css, array(
        '&' => '&amp;',
        '<' => '&lt;',
        '>' => '&gt;',
        "'" => '&apos;',
        '"' => '&quot;',
    ) );
}

#2 @nextendweb
5 days ago

Related PR which cause the problem: https://github.com/WordPress/wordpress-develop/pull/9248

See #63724

@dmsnell

Last edited 5 days ago by westonruter (previous) (diff)

This ticket was mentioned in Slack in #core by juanmaguitar. View the logs.


5 days ago

#4 @westonruter
5 days ago

  • Keywords needs-patch added
  • Milestone changed from Awaiting Review to 7.0.1
  • Severity changed from major to normal

I just tried this with adding a background image to a Group block as an author role user, and it didn't cause the issue. This appears to be because the background image is not actually added as an inline style in the block markup:

<!-- wp:group {"style":{"background":{"backgroundImage":{"url":"http://localhost:8000/wp-content/uploads/2025/11/Bison_with_its_young-2560w.jpg","id":37,"source":"file","title":"Bison_with_its_young-2560w"},"backgroundSize":"cover"}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group"><!-- wp:paragraph -->
<p>Hey!</p>
<!-- /wp:paragraph --></div>
<!-- /wp:group -->

Rendered start tag:

<div style="background-image:url('http://localhost:8000/wp-content/uploads/2025/11/Bison_with_its_young-2560w.jpg');background-size:cover;" class="wp-block-group has-global-padding is-layout-constrained wp-block-group-is-layout-constrained has-background">

However, if I copy the rendered HTML of the Group block and paste it into a Custom HTML block, then indeed I can reproduce the issue:

<div style=");background-size:cover" class="wp-block-group has-global-padding is-layout-constrained wp-block-group-is-layout-constrained has-background">

So I don't think this will cause issues for most users, but it is definitely a bug that needs to be fixed.

#5 @audrasjb
5 days ago

Removing trunk version as this is not going to be shipped with WP 7.0 but in the next releases.

#6 @dmsnell
5 days ago

@nextendweb as unfortunate as it is, I’m glad you found this issue. this arose because of the fix you identified in wp_kses_hair(), but I don’t assess this as a regression. if we go back to the report in the description

<?php
echo wp_kses( '<div style="background-image: url(&quot;https://localhost/image.jpg&quot;);"></div>', $allowed );

this case alone demonstrates the problem in safecss_filter_attr(), which never handled this valid and common style attribute. the root of the problem appears to be that safecss_filter_attr() assumes it will receive decoded HTML values and return decoded HTML values, but wp_kses_hair() and wp_kses_attr_check() are assuming raw and unescaped content.

the change in 7.0 is that we made wp_kses_hair() normalize inputs to prevent further breakage downstream, which is a common phenomena in Core. to that end, this one particular case will appear as a regression (even while many other cases were resolved), but the problem is in the interaction of raw and escaped HTML, plus the bigger problem that we can’t split CSS by ;. so even if we fix or revert the change to wp_kses_hair() and bring back the other broken scenarios, we still haven’t fixed the bug that broke your code.

in the meantime, if we want to address this, I think the least harmful resolution would be to wrap the call to safecss_filter_attr() in wp_kses_attr_check() so that it escapes and then unescapes the HTML.

diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php
index 062f853085..77c3701a66 100644
--- a/src/wp-includes/kses.php
+++ b/src/wp-includes/kses.php
@@ -1556,7 +1556,8 @@ function wp_kses_attr_check( &$name, &$value, &$whole, $vless, $element, $allowe
 	}
 
 	if ( 'style' === $name_low ) {
-		$new_value = safecss_filter_attr( $value );
+		$decoded_value = WP_HTML_Decoder::decode_attribute( $value );
+		$new_value     = safecss_filter_attr( $decoded_value );
 
 		if ( empty( $new_value ) ) {
 			$name  = '';
@@ -1565,7 +1566,7 @@ function wp_kses_attr_check( &$name, &$value, &$whole, $vless, $element, $allowe
 			return false;
 		}
 
-		$whole = str_replace( $value, $new_value, $whole );
+		$whole = str_replace( $value, esc_attr( $new_value ), $whole );
 		$value = $new_value;
 	}
 

This ticket was mentioned in PR #11868 on WordPress/wordpress-develop by @dmsnell.


5 days ago
#7

  • Keywords has-patch added; needs-patch removed

Track ticket: Core-65270

safecss_filter_attr() assumes that it receives already-unescaped HTML attribute values. For example, consider the raw HTML string:

style="background:url(&quot;bg.png&quot;)"

This should be decoded and passed into safecss_filter_attr() as:

background:url("bg.png")

Unfortuantely this hasn’t been done in wp_kses_attr_check(), which takes the output from wp_kses_hair() and sends it directly to the filtering function.

In this patch, wp_kses_attr_check() unescapes the style attribute, filters it, and then re-escapes it when updating the style value.

#8 @westonruter
5 days ago

  • Keywords needs-unit-tests added

#9 @ekamran
34 hours ago

Test Report

Patch tested: https://github.com/WordPress/wordpress-develop/pull/11868

Environment

  • WordPress: 7.1-alpha-62161-src
  • PHP: 8.3.31
  • Environment: wordpress-develop local Docker environment
  • Test method: WP-CLI wp eval automated input/output checks
  • OS: macOS

Results

I was able to reproduce the malformed CSS output on clean trunk.

Before applying the patch, the tested cases returned:

{
  "single_quoted_url": "<div style=\")\"></div>",
  "entity_quoted_url": "<div style=\")\"></div>",
  "url_with_query_ampersand": "<div></div>",
  "url_plus_background_size": "<div style=\");background-size:cover\"></div>"
}

After applying PR #11868 locally, the same cases returned valid sanitized output:

{
  "single_quoted_url": "<div style=\"background-image: url(&#039;https://localhost/image.jpg&#039;)\"></div>",
  "entity_quoted_url": "<div style=\"background-image: url(&quot;https://localhost/image.jpg&quot;)\"></div>",
  "url_with_query_ampersand": "<div style=\"background-image: url(https://localhost/image.jpg?a=1&amp;b=2)\"></div>",
  "url_plus_background_size": "<div style=\"background-image:url(&#039;https://localhost/image.jpg&#039;);background-size:cover\"></div>"
}

I also ran the existing KSES PHPUnit tests with the patch applied:

npm run test:php -- --filter Tests_Kses

OK (414 tests, 1364 assertions)

Notes

The patch fixes the malformed style=")" output in the tested cases and preserves/escapes valid CSS URL values correctly.

Dedicated unit test coverage is still needed for these cases.

Note: See TracTickets for help on using tickets.