Make WordPress Core


Ignore:
Timestamp:
01/15/2026 12:08:01 PM (3 months ago)
Author:
jonsurrell
Message:

Customize: Allow arbitrary CSS in global styles custom CSS.

Relax Global Styles custom CSS filters to allow arbitrary CSS.

Escape HTML characters <>& in Global Styles data to prevent it from being mangled by post content filters. The data is JSON encoded and stored in post_content. Filters operating on post_content expect it to contain HTML. Some KSES filters would otherwise remove essential CSS features like the <custom-ident> CSS data type because they appear to be HTML tags.

[61418] changed STYLE tag generation to use the HTML API for improved safety.

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

Props jonsurrell, dmsnell, westonruter, ramonopoly, oandregal, jorgefilipecosta, sabernhardt, soyebsalar01.
See #64418.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/tests/phpunit/tests/rest-api/rest-global-styles-controller.php

    r60359 r61486  
    651651     * @covers WP_REST_Global_Styles_Controller::update_item
    652652     * @ticket 57536
     653     * @ticket 64418
    653654     */
    654655    public function test_update_item_invalid_styles_css() {
     
    660661        $request->set_body_params(
    661662            array(
    662                 'styles' => array( 'css' => '<p>test</p> body { color: red; }' ),
     663                'styles' => array( 'css' => '</style>' ),
    663664            )
    664665        );
     
    827828        $this->assertSame( 'integer', $route_data[0]['args']['id']['type'] );
    828829    }
     830
     831    /**
     832     * @covers WP_REST_Global_Styles_Controller::update_item
     833     * @ticket 64418
     834     */
     835    public function test_update_allows_valid_css_with_more_syntax() {
     836        wp_set_current_user( self::$admin_id );
     837        if ( is_multisite() ) {
     838            grant_super_admin( self::$admin_id );
     839        }
     840        $request = new WP_REST_Request( 'PUT', '/wp/v2/global-styles/' . self::$global_styles_id );
     841        $css     = <<<'CSS'
     842@property --animate {
     843    syntax: "<custom-ident>";
     844    inherits: true;
     845    initial-value: false;
    829846}
     847h1::before { content: "fun & games"; }
     848CSS;
     849        $request->set_body_params(
     850            array(
     851                'styles' => array( 'css' => $css ),
     852            )
     853        );
     854
     855        $response = rest_get_server()->dispatch( $request );
     856        $data     = $response->get_data();
     857        $this->assertSame( $css, $data['styles']['css'] );
     858
     859        // Compare expected API output to WP internal values.
     860        $request  = new WP_REST_Request( 'GET', '/wp/v2/global-styles/' . self::$global_styles_id );
     861        $response = rest_get_server()->dispatch( $request );
     862        $this->assertSame( $css, $response->get_data()['styles']['css'] );
     863    }
     864
     865    /**
     866     * @covers WP_REST_Global_Styles_Controller::validate_custom_css
     867     * @ticket 64418
     868     *
     869     * @dataProvider data_custom_css_allowed
     870     */
     871    public function test_validate_custom_css_allowed( string $custom_css ) {
     872        $controller = new WP_REST_Global_Styles_Controller();
     873        $validate   = Closure::bind(
     874            function ( $css ) {
     875                return $this->validate_custom_css( $css );
     876            },
     877            $controller,
     878            $controller
     879        );
     880
     881        $this->assertTrue( $validate( $custom_css ) );
     882    }
     883
     884    /**
     885     * Data provider.
     886     *
     887     * @return array<string, string[]>
     888     */
     889    public static function data_custom_css_allowed(): array {
     890        return array(
     891            '@property declaration'   => array(
     892                '@property --prop { syntax: "<custom-ident>"; inherits: true; initial-value: false; }',
     893            ),
     894            'Different close tag'     => array( '</stylesheet>' ),
     895            'Not a style close tag'   => array( '/*</style*/' ),
     896            'Not a style close tag 2' => array( '/*</style_' ),
     897            'Empty'                   => array( '' ),
     898            'Short content'           => array( '/**/' ),
     899        );
     900    }
     901
     902    /**
     903     * @covers WP_REST_Global_Styles_Controller::validate_custom_css
     904     * @ticket 64418
     905     *
     906     * @dataProvider data_custom_css_disallowed
     907     */
     908    public function test_validate_custom_css( string $custom_css, string $expected_error_message ) {
     909        $controller = new WP_REST_Global_Styles_Controller();
     910        $validate   = Closure::bind(
     911            function ( $css ) {
     912                return $this->validate_custom_css( $css );
     913            },
     914            $controller,
     915            $controller
     916        );
     917
     918        $result = $validate( $custom_css );
     919        $this->assertWPError( $result );
     920        $this->assertSame( $expected_error_message, $result->get_error_message() );
     921    }
     922
     923    /**
     924     * Data provider.
     925     *
     926     * @return array<string, string[]>
     927     */
     928    public static function data_custom_css_disallowed(): array {
     929        return array(
     930            'style close tag'            => array( 'css…</style>…css', 'The CSS must not contain "&lt;/style&gt;".' ),
     931            'style close tag upper case' => array( '</STYLE>', 'The CSS must not contain "&lt;/STYLE&gt;".' ),
     932            'style close tag mixed case' => array( '</sTyLe>', 'The CSS must not contain "&lt;/sTyLe&gt;".' ),
     933            'style close tag in comment' => array( '/*</style>*/', 'The CSS must not contain "&lt;/style&gt;".' ),
     934            'style close tag (/)'        => array( '</style/', 'The CSS must not contain "&lt;/style/".' ),
     935            'style close tag (\t)'       => array( "</style\t", "The CSS must not contain \"&lt;/style\t\"." ),
     936            'style close tag (\f)'       => array( "</style\f", "The CSS must not contain \"&lt;/style\f\"." ),
     937            'style close tag (\r)'       => array( "</style\r", "The CSS must not contain \"&lt;/style\r\"." ),
     938            'style close tag (\n)'       => array( "</style\n", "The CSS must not contain \"&lt;/style\n\"." ),
     939            'style close tag (" ")'      => array( '</style ', 'The CSS must not contain "&lt;/style ".' ),
     940            'truncated "<"'              => array( '<', 'The CSS must not end in "&lt;".' ),
     941            'truncated "</"'             => array( '</', 'The CSS must not end in "&lt;/".' ),
     942            'truncated "</s"'            => array( '</s', 'The CSS must not end in "&lt;/s".' ),
     943            'truncated "</ST"'           => array( '</ST', 'The CSS must not end in "&lt;/ST".' ),
     944            'truncated "</sty"'          => array( '</sty', 'The CSS must not end in "&lt;/sty".' ),
     945            'truncated "</STYL"'         => array( '</STYL', 'The CSS must not end in "&lt;/STYL".' ),
     946            'truncated "</stYle"'        => array( '</stYle', 'The CSS must not end in "&lt;/stYle".' ),
     947        );
     948    }
     949}
Note: See TracChangeset for help on using the changeset viewer.