Make WordPress Core

Changeset 61527


Ignore:
Timestamp:
01/26/2026 03:17:00 PM (3 weeks 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.