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: |
|
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("https://localhost/image.jpg");"></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 " 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 => trueis the expected way to allow thestyleattribute inwp_kses().httpsis an allowed protocol by default.- The issue reproduces regardless of whether quotes inside
url(...)are literal or entity encoded ("). - 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)
#2
@
5 days ago
Related PR which cause the problem: https://github.com/WordPress/wordpress-develop/pull/9248
See #63724
@dmsnell
This ticket was mentioned in Slack in #core by juanmaguitar. View the logs.
5 days ago
#4
@
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
@
5 days ago
Removing trunk version as this is not going to be shipped with WP 7.0 but in the next releases.
#6
@
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("https://localhost/image.jpg");"></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("bg.png")"
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.
#9
@
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 evalautomated 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('https://localhost/image.jpg')\"></div>",
"entity_quoted_url": "<div style=\"background-image: url("https://localhost/image.jpg")\"></div>",
"url_with_query_ampersand": "<div style=\"background-image: url(https://localhost/image.jpg?a=1&b=2)\"></div>",
"url_plus_background_size": "<div style=\"background-image:url('https://localhost/image.jpg');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.
Another example with & which also breaks
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( '&' => '&', '<' => '<', '>' => '>', "'" => ''', '"' => '"', ); 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: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:
safecss_filter_attr()The malformed result appears to happen when
safecss_filter_attr()splits declarations withexplode( ';', ... ):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
'contains a semicolon, the CSS is split incorrectly:background-image: url('https://localhost/image.jpg');becomes roughly:
The last fragment survives sanitization and produces:
Possible Solutions
Solution 1: Preserve raw quote characters in
wp_kses_hair()Avoid storing the entity-encoded value in the internal
valuefield returned bywp_kses_hair().Potential adjustment:
This keeps
valueas:background-image: url('https://localhost/image.jpg');while
wholecan remain safely escaped for HTML reconstruction.Pros:
url(...)values.Cons:
".Solution 2: Decode HTML entities before CSS declaration parsing
Decode HTML entities in
safecss_filter_attr()before splitting CSS declarations:before:
This converts:
url('https://localhost/image.jpg')back into:
url('https://localhost/image.jpg')before
explode( ';', ... )runs.Pros:
".Cons:
safecss_filter_attr().Solution 3: Decode HTML entities before CSS declaration parsing and then enconde again