Make WordPress Core

Changeset 61527


Ignore:
Timestamp:
01/26/2026 03:17:00 PM (7 months ago)
Author:
jonsurrell
Message:

Customize: Allow arbitrary custom CSS.

Update custom CSS validation to allow any CSS except STYLE close tags. Previously, some valid CSS would be rejected for containing HTML syntax characters, like this example:

@property --animate {
  syntax: "<custom-ident>"; /* <-- Validation error on `<` */
  inherits: true;
  initial-value: false;
}

Developed in https://github.com/WordPress/wordpress-develop/pull/10667.

Follow-up to [61418], [61486].

Props jonsurrell, westonruter, peterwilsoncc, johnbillion, xknown, sabernhardt, dmsnell, soyebsalar01, dlh.
Fixes #64418.

Location:
trunk
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/customize/class-wp-customize-custom-css-setting.php

    r60215 r61527  
    154154         * @since 4.9.0 Checking for balanced characters has been moved client-side via linting in code editor.
    155155         * @since 5.9.0 Renamed `$css` to `$value` for PHP 8 named parameter support.
     156         * @since 7.0.0 Only restricts contents which risk prematurely closing the STYLE element,
     157         *              either through a STYLE end tag or a prefix of one which might become a
     158         *              full end tag when combined with the contents of other styles.
     159         *
     160         * @see WP_REST_Global_Styles_Controller::validate_custom_css()
    156161         *
    157162         * @param string $value CSS to validate.
     
    164169                $validity = new WP_Error();
    165170
    166                 if ( preg_match( '#</?\w+#', $css ) ) {
    167                         $validity->add( 'illegal_markup', __( 'Markup is not allowed in CSS.' ) );
     171                $length = strlen( $css );
     172                for (
     173                        $at = strcspn( $css, '<' );
     174                        $at < $length;
     175                        $at += strcspn( $css, '<', ++$at )
     176                ) {
     177                        $remaining_strlen = $length - $at;
     178                        /**
     179                         * Custom CSS text is expected to render inside an HTML STYLE element.
     180                         * A STYLE closing tag must not appear within the CSS text because it
     181                         * would close the element prematurely.
     182                         *
     183                         * The text must also *not* end with a partial closing tag (e.g., `<`,
     184                         * `</`, … `</style`) because subsequent styles which are concatenated
     185                         * could complete it, forming a valid `</style>` tag.
     186                         *
     187                         * Example:
     188                         *
     189                         *     $style_a = 'p { font-weight: bold; </sty';
     190                         *     $style_b = 'le> gotcha!';
     191                         *     $combined = "{$style_a}{$style_b}";
     192                         *
     193                         *     $style_a = 'p { font-weight: bold; </style';
     194                         *     $style_b = 'p > b { color: red; }';
     195                         *     $combined = "{$style_a}\n{$style_b}";
     196                         *
     197                         * Note how in the second example, both of the style contents are benign
     198                         * when analyzed on their own. The first style was likely the result of
     199                         * improper truncation, while the second is perfectly sound. It was only
     200                         * through concatenation that these two styles combined to form content
     201                         * that would have broken out of the containing STYLE element, thus
     202                         * corrupting the page and potentially introducing security issues.
     203                         *
     204                         * @see https://html.spec.whatwg.org/multipage/parsing.html#rawtext-end-tag-name-state
     205                         */
     206                        $possible_style_close_tag = 0 === substr_compare(
     207                                $css,
     208                                '</style',
     209                                $at,
     210                                min( 7, $remaining_strlen ),
     211                                true
     212                        );
     213                        if ( $possible_style_close_tag ) {
     214                                if ( $remaining_strlen < 8 ) {
     215                                        $validity->add(
     216                                                'illegal_markup',
     217                                                sprintf(
     218                                                        /* translators: %s is the CSS that was provided. */
     219                                                        __( 'The CSS must not end in "%s".' ),
     220                                                        esc_html( substr( $css, $at ) )
     221                                                )
     222                                        );
     223                                        break;
     224                                }
     225
     226                                if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) {
     227                                        $validity->add(
     228                                                'illegal_markup',
     229                                                sprintf(
     230                                                        /* translators: %s is the CSS that was provided. */
     231                                                        __( 'The CSS must not contain "%s".' ),
     232                                                        esc_html( substr( $css, $at, 8 ) )
     233                                                )
     234                                        );
     235                                        break;
     236                                }
     237                        }
    168238                }
    169239
  • trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php

    r61486 r61527  
    674674         *              either through a STYLE end tag or a prefix of one which might become a
    675675         *              full end tag when combined with the contents of other styles.
     676         *
     677         * @see WP_Customize_Custom_CSS_Setting::validate()
    676678         *
    677679         * @param string $css CSS to validate.
     
    708710                         * when analyzed on their own. The first style was likely the result of
    709711                         * improper truncation, while the second is perfectly sound. It was only
    710                          * through concatenation that these two scripts combined to form content
     712                         * through concatenation that these two styles combined to form content
    711713                         * that would have broken out of the containing STYLE element, thus
    712714                         * corrupting the page and potentially introducing security issues.
  • trunk/tests/phpunit/tests/customize/custom-css-setting.php

    r60253 r61527  
    376376
    377377        /**
     378         * Ensure that dangerous STYLE tag contents do not break HTML output.
     379         *
     380         * @ticket 64418
     381         * @covers ::wp_update_custom_css_post
     382         * @covers ::wp_custom_css_cb
     383         */
     384        public function test_wp_custom_css_cb_escapes_dangerous_html() {
     385                wp_update_custom_css_post(
     386                        '*::before { content: "</style><script>alert(1)</script>"; }',
     387                        array(
     388                                'stylesheet' => $this->setting->stylesheet,
     389                        )
     390                );
     391                $output   = get_echo( 'wp_custom_css_cb' );
     392                $expected =
     393                        <<<'HTML'
     394                        <style id="wp-custom-css">
     395                        *::before { content: "\3c\2fstyle><script>alert(1)</script>"; }
     396                        </style>
     397
     398                        HTML;
     399                $this->assertEqualHTML( $expected, $output );
     400        }
     401
     402        /**
    378403         * Tests that validation errors are caught appropriately.
    379404         *
     
    383408         * @covers WP_Customize_Custom_CSS_Setting::validate
    384409         */
    385         public function test_validate() {
    386 
     410        public function test_validate_basic_css() {
    387411                // Empty CSS throws no errors.
    388412                $result = $this->setting->validate( '' );
     
    394418                $this->assertTrue( $result );
    395419
    396                 // Check for markup.
     420                // Check for illegal closing STYLE tag.
    397421                $unclosed_comment = $basic_css . '</style>';
    398422                $result           = $this->setting->validate( $unclosed_comment );
    399423                $this->assertArrayHasKey( 'illegal_markup', $result->errors );
    400424        }
     425
     426        /**
     427         * @ticket 64418
     428         * @covers WP_Customize_Custom_CSS_Setting::validate
     429         */
     430        public function test_validate_accepts_css_property_at_rule() {
     431                $css =
     432                        <<<'CSS'
     433                        @property --animate {
     434                                syntax: "<custom-ident>";
     435                                inherits: true;
     436                                initial-value: false;
     437                        }
     438                        CSS;
     439                $this->assertTrue( $this->setting->validate( $css ) );
     440        }
     441
     442        /**
     443         * @ticket 64418
     444         * @covers ::wp_update_custom_css_post
     445         * @covers ::wp_custom_css_cb
     446         */
     447        public function test_save_and_print_property_at_rule() {
     448                $css =
     449                        <<<'CSS'
     450                        @property --animate {
     451                                syntax: "<custom-ident>";
     452                                inherits: true;
     453                                initial-value: false;
     454                        }
     455                        CSS;
     456                wp_update_custom_css_post( $css, array( 'stylesheet' => $this->setting->stylesheet ) );
     457                $output   = get_echo( 'wp_custom_css_cb' );
     458                $expected = "<style id='wp-custom-css'>\n{$css}\n</style>\n";
     459                $this->assertEqualHTML( $expected, $output );
     460        }
     461
     462        /**
     463         * @dataProvider data_custom_css_disallowed
     464         *
     465         * @ticket 64418
     466         * @covers WP_Customize_Custom_CSS_Setting::validate
     467         */
     468        public function test_validate_prevents( $css, $expected_error_message ) {
     469                $result = $this->setting->validate( $css );
     470                $this->assertWPError( $result );
     471                $this->assertSame( $expected_error_message, $result->get_error_message() );
     472        }
     473
     474        /**
     475         * Data provider.
     476         *
     477         * @return array<string, string[]>
     478         */
     479        public static function data_custom_css_disallowed(): array {
     480                return array(
     481                        'style close tag'            => array( 'css…</style>…css', 'The CSS must not contain "&lt;/style&gt;".' ),
     482                        'style close tag upper case' => array( '</STYLE>', 'The CSS must not contain "&lt;/STYLE&gt;".' ),
     483                        'style close tag mixed case' => array( '</sTyLe>', 'The CSS must not contain "&lt;/sTyLe&gt;".' ),
     484                        'style close tag in comment' => array( '/*</style>*/', 'The CSS must not contain "&lt;/style&gt;".' ),
     485                        'style close tag (/)'        => array( '</style/', 'The CSS must not contain "&lt;/style/".' ),
     486                        'style close tag (\t)'       => array( "</style\t", "The CSS must not contain \"&lt;/style\t\"." ),
     487                        'style close tag (\f)'       => array( "</style\f", "The CSS must not contain \"&lt;/style\f\"." ),
     488                        'style close tag (\r)'       => array( "</style\r", "The CSS must not contain \"&lt;/style\r\"." ),
     489                        'style close tag (\n)'       => array( "</style\n", "The CSS must not contain \"&lt;/style\n\"." ),
     490                        'style close tag (" ")'      => array( '</style ', 'The CSS must not contain "&lt;/style ".' ),
     491                        'truncated "<"'              => array( '<', 'The CSS must not end in "&lt;".' ),
     492                        'truncated "</"'             => array( '</', 'The CSS must not end in "&lt;/".' ),
     493                        'truncated "</s"'            => array( '</s', 'The CSS must not end in "&lt;/s".' ),
     494                        'truncated "</ST"'           => array( '</ST', 'The CSS must not end in "&lt;/ST".' ),
     495                        'truncated "</sty"'          => array( '</sty', 'The CSS must not end in "&lt;/sty".' ),
     496                        'truncated "</STYL"'         => array( '</STYL', 'The CSS must not end in "&lt;/STYL".' ),
     497                        'truncated "</stYle"'        => array( '</stYle', 'The CSS must not end in "&lt;/stYle".' ),
     498                );
     499        }
    401500}
Note: See TracChangeset for help on using the changeset viewer.