diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php index 39e18f7dd6e14..0a5c707f58818 100644 --- a/src/wp-includes/class-wp-styles.php +++ b/src/wp-includes/class-wp-styles.php @@ -135,6 +135,8 @@ public function __construct() { * * @since 2.6.0 * @since 5.5.0 Added the `$group` parameter. + * @since 7.3.0 The attributes of the stylesheet link tags can be filtered with + * {@see 'wp_style_attributes'}. * * @see WP_Dependencies::do_item() * @@ -178,7 +180,8 @@ public function do_item( $handle, $group = false ) { return true; } - $href = $this->_css_href( $src, $obj->ver, $handle ); + $raw_href = $this->get_src( $handle ); + $href = esc_url( $raw_href ); if ( ! $href ) { return true; } @@ -186,14 +189,22 @@ public function do_item( $handle, $group = false ) { $rel = isset( $obj->extra['alt'] ) && $obj->extra['alt'] ? 'alternate stylesheet' : 'stylesheet'; $title = $obj->extra['title'] ?? ''; - $tag = sprintf( - "\n", - $rel, - esc_attr( $handle ), - $title ? sprintf( " title='%s'", esc_attr( $title ) ) : '', - $href, - esc_attr( $media ) + /* + * The title and media are cast to strings, as esc_attr() did when the tag was built as a + * string, so that a value such as `true` is still printed as `'1'`, and an array passed by + * mistake as `'Array'`, rather than as a boolean attribute or a fatal error. + */ + $attributes = array( + 'rel' => $rel, + 'id' => "{$handle}-css", ); + if ( $title ) { + $attributes['title'] = (string) $title; + } + $attributes['href'] = $raw_href; + $attributes['media'] = (string) $media; + + $tag = _wp_get_legacy_style_link_tag( $attributes, __METHOD__, array( 'href' => $href ) ); /** * Filters the HTML link tag of an enqueued style. @@ -212,16 +223,17 @@ public function do_item( $handle, $group = false ) { $rtl_src = $this->get_rtl_src( $handle ); if ( null !== $rtl_src ) { - $rtl_href = esc_url( $rtl_src ); - $rtl_tag = sprintf( - "\n", - $rel, - esc_attr( $handle ), - $title ? sprintf( " title='%s'", esc_attr( $title ) ) : '', - $rtl_href, - esc_attr( $media ) + $rtl_href = esc_url( $rtl_src ); + $rtl_attributes = array_merge( + $attributes, + array( + 'id' => "{$handle}-rtl-css", + 'href' => $rtl_src, + ) ); + $rtl_tag = _wp_get_legacy_style_link_tag( $rtl_attributes, __METHOD__, array( 'href' => $rtl_href ) ); + /** This filter is documented in wp-includes/class-wp-styles.php */ $rtl_tag = apply_filters( 'style_loader_tag', $rtl_tag, $handle, $rtl_href, $media ); @@ -415,6 +427,9 @@ public function all_deps( $handles, $recursion = false, $group = false ) { * Generates an enqueued style's fully-qualified URL. * * @since 2.6.0 + * @since 7.3.0 No longer used by WP_Styles::do_item(), which escapes the URL from + * WP_Styles::get_src() instead, so overriding this method in a subclass + * no longer changes the printed URL. * * @param string $src The source of the enqueued style. * @param string|false|null $ver The version of the enqueued style. diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 5d6786db99bc8..313be7b770318 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -5380,6 +5380,8 @@ function wp_admin_css_uri( $file = 'wp-admin' ) { * stylesheet link to that generated URL is printed. * * @since 2.3.0 + * @since 7.3.0 The attributes of the printed stylesheet link tags can be filtered with + * {@see 'wp_style_attributes'}. * * @param string $file Optional. Style handle name or file name (without ".css" extension) relative * to wp-admin/. Defaults to 'wp-admin'. @@ -5400,9 +5402,12 @@ function wp_admin_css( $file = 'wp-admin', $force_echo = false ) { return; } - $stylesheet_link = sprintf( - "\n", - esc_url( wp_admin_css_uri( $file ) ) + $stylesheet_link = _wp_get_legacy_style_link_tag( + array( + 'rel' => 'stylesheet', + 'href' => wp_admin_css_uri( $file ), + ), + __FUNCTION__ ); /** @@ -5419,9 +5424,12 @@ function wp_admin_css( $file = 'wp-admin', $force_echo = false ) { echo apply_filters( 'wp_admin_css', $stylesheet_link, $file ); if ( function_exists( 'is_rtl' ) && is_rtl() ) { - $rtl_stylesheet_link = sprintf( - "\n", - esc_url( wp_admin_css_uri( "$file-rtl" ) ) + $rtl_stylesheet_link = _wp_get_legacy_style_link_tag( + array( + 'rel' => 'stylesheet', + 'href' => wp_admin_css_uri( "$file-rtl" ), + ), + __FUNCTION__ ); /** This filter is documented in wp-includes/general-template.php */ diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index fc3ebbd305168..cfe536aac0252 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -3420,6 +3420,226 @@ function wp_print_inline_script_tag( $data, $attributes = array() ) { echo wp_get_inline_script_tag( $data, $attributes ); } +/** + * Formats a `` tag. + * + * It is possible to inject attributes in the stylesheet link tag via the {@see 'wp_style_attributes'} filter. + * + * Attributes are printed in the order they are given, so `rel` can be printed first, as is conventional. + * + * @since 7.3.0 + * + * @param array $attributes Key-value pairs representing `` tag attributes. Only + * the attribute name is added for entries whose value is `true` + * or `null`, as for wp_get_script_tag(), and entries whose value + * is `false` are omitted. + * @return string String containing a `` tag. + * + * @phpstan-param array $attributes + * @phpstan-return non-falsy-string + */ +function wp_get_style_link_tag( array $attributes ): string { + return _wp_build_style_link_tag( _wp_filter_style_link_attributes( $attributes, __FUNCTION__ ) ); +} + +/** + * Prints a formatted stylesheet link tag. + * + * It is possible to inject attributes in the stylesheet link tag via the {@see 'wp_style_attributes'} filter. + * + * @since 7.3.0 + * + * @param array $attributes Key-value pairs representing `` tag attributes. Only + * the attribute name is added for entries whose value is `true` + * or `null`, as for wp_get_script_tag(), and entries whose value + * is `false` are omitted. + * + * @phpstan-param array $attributes + */ +function wp_print_style_link_tag( array $attributes ): void { + echo wp_get_style_link_tag( $attributes ); +} + +/** + * Formats a stylesheet link tag in the markup Core printed before wp_get_style_link_tag(). + * + * Callbacks for filters such as {@see 'style_loader_tag'} and {@see 'wp_admin_css'} have long + * edited that markup as a string, so the values supplied here are printed as they were before: + * escaped with esc_url() for the `href` attribute and with esc_attr() otherwise, and single-quoted. + * A value which the {@see 'wp_style_attributes'} filter adds or changes is escaped by the Tag + * Processor instead. + * + * @since 7.3.0 + * @access private + * + * @param array $attributes Key-value pairs representing `` tag attributes. + * @param string $function_name The function printing the tag, named in the notice + * issued when the filter changes the `id` attribute. + * @param array $escaped_values Optional. Values from `$attributes` which have already + * been escaped, keyed by attribute name, such as an `href` + * escaped with esc_url() for another use, so that they are + * not escaped again. Default empty array. + * @return string String containing a `` tag. + * + * @phpstan-param array $attributes + * @phpstan-return non-falsy-string + */ +function _wp_get_legacy_style_link_tag( array $attributes, string $function_name, array $escaped_values = array() ): string { + $filtered_attributes = _wp_filter_style_link_attributes( $attributes, $function_name ); + + $legacy_values = array(); + foreach ( $attributes as $name => $value ) { + if ( ! is_string( $value ) || ! array_key_exists( $name, $filtered_attributes ) || $filtered_attributes[ $name ] !== $value ) { + continue; + } + + if ( isset( $escaped_values[ $name ] ) ) { + $legacy_values[ $name ] = $escaped_values[ $name ]; + } elseif ( 'href' === $name ) { + $legacy_values[ $name ] = esc_url( $value ); + } else { + $legacy_values[ $name ] = esc_attr( $value ); + } + } + + // Attribute values are escaped, so the only double quotes in the tag are those delimiting them. + return str_replace( '"', "'", _wp_build_style_link_tag( $filtered_attributes, $legacy_values ) ); +} + +/** + * Applies the {@see 'wp_style_attributes'} filter to the attributes of a stylesheet link tag. + * + * @since 7.3.0 + * @access private + * + * @param array $attributes Key-value pairs representing `` tag attributes. + * @param string $function_name The function printing the tag, named in the notice + * issued when the filter changes the `id` attribute. + * @return array Filtered attributes. + * + * @phpstan-param array $attributes + */ +function _wp_filter_style_link_attributes( array $attributes, string $function_name ) { + /** + * Filters attributes to be added to a stylesheet link tag. + * + * This applies to the stylesheet links printed by Core, including those for enqueued styles, + * whose `id` attribute is the style's handle with `-css` appended, or `-rtl-css` for a + * right-to-left stylesheet. For an enqueued style the URL passed to the + * {@see 'style_loader_tag'} filter is the one from before this filter, even when this + * filter changes the `href` attribute. + * + * Attribute values are not yet escaped. The `href` attribute is escaped with esc_url() when the + * tag is printed, which applies the {@see 'clean_url'} filter and omits a URL with a disallowed + * protocol, so the printed URL may differ from the one passed to this filter. + * + * The `id` attribute, when supplied, cannot be changed or removed by this filter, since other + * code may locate the `` tag by it. For example, the user profile screen locates the + * `colors-css` stylesheet to preview a color scheme. + * + * @since 7.3.0 + * + * @param array $attributes Key-value pairs representing `` tag attributes. + * Only the attribute name is added to the `` tag + * for entries whose value is `true` or `null`, and + * entries whose value is `false` are omitted. + */ + $filtered_attributes = apply_filters( 'wp_style_attributes', $attributes ); + + if ( ! isset( $attributes['id'] ) ) { + return $filtered_attributes; + } + + if ( ( $filtered_attributes['id'] ?? null ) !== $attributes['id'] ) { + _doing_it_wrong( + $function_name, + sprintf( + /* translators: 1: id, 2: wp_style_attributes */ + __( 'The %1$s attribute cannot be changed by the %2$s filter.' ), + 'id', + 'wp_style_attributes' + ), + '7.3.0' + ); + } + + // Retain the supplied ID in place of the first lexical variation of its name, or after the others when removed. + $retained_attributes = array(); + foreach ( $filtered_attributes as $name => $value ) { + if ( 'id' !== strtolower( (string) $name ) ) { + $retained_attributes[ $name ] = $value; + } elseif ( ! isset( $retained_attributes['id'] ) ) { + $retained_attributes['id'] = $attributes['id']; + } + } + $retained_attributes['id'] = $attributes['id']; + + return $retained_attributes; +} + +/** + * Builds a `` tag from attributes which have already been filtered. + * + * @since 7.3.0 + * @access private + * + * @param array $attributes Key-value pairs representing `` tag attributes. + * @param array $escaped_values Optional. Values which have already been escaped, keyed + * by attribute name, to print as-is. Default empty array. + * @return string String containing a `` tag. + * + * @phpstan-return non-falsy-string + */ +function _wp_build_style_link_tag( $attributes, array $escaped_values = array() ): string { + $html = ' $value ) { + $name = (string) $name; + + /* + * Lexical variations of an attribute name may represent the same attribute in HTML. + * Calling code should rewrite an attribute's value rather than sending a duplicate, + * so only the first is used, even when its value omits the attribute. + */ + $normalized_name = strtolower( $name ); + if ( isset( $seen[ $normalized_name ] ) ) { + continue; + } + $seen[ $normalized_name ] = true; + + if ( false === $value ) { + continue; + } + + if ( isset( $escaped_values[ $name ] ) ) { + // Escaping functions are filterable, so a quote must not end the value early. + $html .= " {$name}=\"" . strtr( + $escaped_values[ $name ], + array( + '"' => '"', + "'" => ''', + ) + ) . '"'; + continue; + } + + /* + * The Tag Processor adds new attributes to a tag in alphabetical order, so each attribute + * is set on a tag of its own, which escapes and validates it, and then printed in the + * order given. A rejected name or value, such as a URL with a disallowed protocol, is + * left out. + */ + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); + if ( $processor->set_attribute( $name, $value ?? true ) ) { + $html .= substr( $processor->get_updated_html(), strlen( '\n"; +} + /** * Allows small styles to be inlined. * diff --git a/src/wp-includes/theme.php b/src/wp-includes/theme.php index d81616c6c552f..4a6d25a5d346a 100644 --- a/src/wp-includes/theme.php +++ b/src/wp-includes/theme.php @@ -726,6 +726,8 @@ function get_raw_theme_root( $stylesheet_or_template, $skip_cache = false ) { * Displays localized stylesheet link element. * * @since 2.1.0 + * @since 7.3.0 The stylesheet URL is escaped, and the attributes of the link tag can be filtered + * with {@see 'wp_style_attributes'}. */ function locale_stylesheet() { $stylesheet = get_locale_stylesheet_uri(); @@ -733,9 +735,12 @@ function locale_stylesheet() { return; } - printf( - '', - $stylesheet + wp_print_style_link_tag( + array( + 'rel' => 'stylesheet', + 'href' => $stylesheet, + 'media' => 'screen', + ) ); } diff --git a/tests/phpunit/tests/dependencies/styles.php b/tests/phpunit/tests/dependencies/styles.php index f7f66978d2775..47350f2f22758 100644 --- a/tests/phpunit/tests/dependencies/styles.php +++ b/tests/phpunit/tests/dependencies/styles.php @@ -76,6 +76,489 @@ public function test_wp_enqueue_style() { $this->assertSame( '', get_echo( 'wp_print_styles' ) ); } + /** + * @ticket 66342 + * + * @covers WP_Styles::do_item + */ + public function test_style_attributes_filter_runs_before_legacy_style_loader_tag_filter() { + $source = 'https://example.com/style.css?one=1&two=2'; + + add_filter( + 'wp_style_attributes', + static function ( array $attributes ): array { + $attributes['nonce'] = 'style-nonce'; + return $attributes; + } + ); + $attributes_filter = new MockAction(); + add_filter( 'wp_style_attributes', array( $attributes_filter, 'filter' ), 20 ); + $legacy_filter = new MockAction(); + add_filter( 'style_loader_tag', array( $legacy_filter, 'filter' ), 10, 4 ); + + wp_enqueue_style( 'filtered-style', $source, array(), null, 'screen' ); + $markup = get_echo( 'wp_print_styles' ); + + $this->assertSame( 1, $attributes_filter->get_call_count() ); + $this->assertSame( $source, $attributes_filter->get_args()[0][0]['href'], 'The attributes filter should be passed the unescaped URL.' ); + + $this->assertSame( 1, $legacy_filter->get_call_count() ); + list( $tag, $handle, $href, $media ) = $legacy_filter->get_args()[0]; + $this->assertSame( 'filtered-style', $handle ); + $this->assertSame( esc_url( $source ), $href, 'The style_loader_tag filter should be passed the escaped URL.' ); + $this->assertSame( 'screen', $media ); + $this->assertSame( $markup, $tag ); + + $processor = new WP_HTML_Tag_Processor( $markup ); + $this->assertTrue( $processor->next_tag( 'link' ) ); + $this->assertSame( 'style-nonce', $processor->get_attribute( 'nonce' ) ); + $this->assertSame( $source, $processor->get_attribute( 'href' ) ); + } + + /** + * @ticket 66342 + * + * @covers WP_Styles::do_item + */ + public function test_style_attribute_quotes_do_not_create_extra_attributes() { + $attribute_value = "It's O'Reilly's \"quoted\" text; data-injected=\"should-remain-text\""; + + add_filter( + 'wp_style_attributes', + static function ( array $attributes ) use ( $attribute_value ): array { + $attributes['data-quote-test'] = $attribute_value; + return $attributes; + } + ); + + wp_enqueue_style( 'quoted-style', 'https://example.com/style.css', array(), null ); + + $processor = new WP_HTML_Tag_Processor( get_echo( 'wp_print_styles' ) ); + $this->assertTrue( $processor->next_tag( 'link' ) ); + $this->assertSame( $attribute_value, $processor->get_attribute( 'data-quote-test' ) ); + $this->assertNull( $processor->get_attribute( 'data-injected' ) ); + } + + /** + * Attributes added by the wp_style_attributes filter are printed after Core's, in the same quotes. + * + * @ticket 66342 + * + * @covers WP_Styles::do_item + * @covers ::_wp_get_legacy_style_link_tag + */ + public function test_rtl_alternate_styles_use_both_attribute_and_legacy_filters() { + global $wp_styles; + + $wp_styles->text_direction = 'rtl'; + + add_filter( + 'wp_style_attributes', + static function ( array $attributes ): array { + $attributes['data-filter'] = 'applied'; + return $attributes; + } + ); + $legacy_filter = new MockAction(); + add_filter( 'style_loader_tag', array( $legacy_filter, 'filter' ), 10, 4 ); + + wp_enqueue_style( 'theme-style', 'https://example.com/theme.css', array(), null ); + wp_style_add_data( 'theme-style', 'alt', true ); + wp_style_add_data( 'theme-style', 'title', 'Alternate theme' ); + wp_style_add_data( 'theme-style', 'rtl', true ); + + $expected = <<<'HTML' + + + +HTML; + + $this->assertSame( $expected, get_echo( 'wp_print_styles' ) ); + $this->assertSame( + array( esc_url( 'https://example.com/theme.css' ), esc_url( 'https://example.com/theme-rtl.css' ) ), + array_column( $legacy_filter->get_args(), 2 ), + 'The style_loader_tag filter should be passed each escaped URL.' + ); + } + + /** + * @ticket 66342 + * + * @covers ::wp_admin_css + */ + public function test_wp_admin_css_uses_style_attributes_and_legacy_filter() { + add_filter( + 'wp_style_attributes', + static function ( array $attributes ): array { + $attributes['nonce'] = 'admin-style'; + return $attributes; + } + ); + $legacy_filter = new MockAction(); + add_filter( 'wp_admin_css', array( $legacy_filter, 'filter' ), 10, 2 ); + + $markup = get_echo( 'wp_admin_css', array( 'style-link-api-test' ) ); + + $this->assertSame( + array( array( $markup, 'style-link-api-test' ) ), + $legacy_filter->get_args(), + 'The wp_admin_css filter should be passed the tag with the filtered attributes.' + ); + + $processor = new WP_HTML_Tag_Processor( $markup ); + $this->assertTrue( $processor->next_tag( 'link' ) ); + $this->assertSame( 'admin-style', $processor->get_attribute( 'nonce' ) ); + $this->assertStringContainsString( 'style-link-api-test.css', $processor->get_attribute( 'href' ) ); + } + + /** + * The URL printed by locale_stylesheet() is escaped. + * + * @ticket 66342 + * + * @covers ::locale_stylesheet + */ + public function test_locale_stylesheet_uses_style_attributes_filter() { + add_filter( + 'locale_stylesheet_uri', + static function (): string { + return 'https://example.com/locale.css?one=1&two=2'; + } + ); + add_filter( + 'wp_style_attributes', + static function ( array $attributes ): array { + $attributes['nonce'] = 'locale-style'; + return $attributes; + } + ); + + $this->assertSame( + "\n", + get_echo( 'locale_stylesheet' ) + ); + } + + /** + * Callbacks for the style_loader_tag filter edit the markup as a string, so it must not change. + * + * This compares exact strings, rather than equivalent HTML, since the quoting style and the order + * of the attributes are what such callbacks rely on. + * + * @ticket 66342 + * + * @covers WP_Styles::do_item + * @covers ::_wp_get_legacy_style_link_tag + */ + public function test_style_loader_tag_markup_is_unchanged() { + global $wp_styles; + + $wp_styles->text_direction = 'rtl'; + + $legacy_filter = new MockAction(); + add_filter( 'style_loader_tag', array( $legacy_filter, 'filter' ) ); + + wp_enqueue_style( 'unchanged', 'https://example.com/style.css?a=1&b=2', array(), '1.0', 'screen' ); + wp_style_add_data( 'unchanged', 'alt', true ); + wp_style_add_data( 'unchanged', 'title', 'Black & "white"' ); + wp_style_add_data( 'unchanged', 'rtl', true ); + + $markup = get_echo( 'wp_print_styles' ); + + $expected = array( + "\n", + "\n", + ); + + $this->assertSame( $expected, array_column( $legacy_filter->get_args(), 0 ), 'The style_loader_tag filter should be passed the historical markup.' ); + $this->assertSame( implode( '', $expected ), $markup, 'The historical markup should be printed.' ); + } + + /** + * Values are escaped with esc_attr() in the markup passed to the style_loader_tag filter, as before. + * + * Each value is one which esc_attr() escapes differently from the Tag Processor, which builds the + * tags printed by wp_get_style_link_tag(). + * + * @ticket 66342 + * + * @covers WP_Styles::do_item + * @covers ::_wp_get_legacy_style_link_tag + * + * @dataProvider data_style_loader_tag_markup_escapes_values_as_before + * + * @param string $handle Style handle. + * @param string|bool $media The media argument passed to wp_enqueue_style(). + * @param string|bool $title Title of the style, or false for none. + * @param string $expected Expected markup. + */ + public function test_style_loader_tag_markup_escapes_values_as_before( $handle, $media, $title, $expected ) { + wp_enqueue_style( $handle, 'https://example.com/style.css', array(), null, $media ); + if ( false !== $title ) { + wp_style_add_data( $handle, 'title', $title ); + } + + $this->assertSame( $expected, get_echo( 'wp_print_styles' ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_style_loader_tag_markup_escapes_values_as_before(): array { + return array( + 'apostrophe in title' => array( 'apostrophe', 'all', "Editor's choice", "\n" ), + 'character references in title' => array( 'references', 'all', 'L'ancienne & co', "\n" ), + 'invalid UTF-8 in title' => array( 'invalid', 'all', "bad\xC3\x28", "\n" ), + 'true title' => array( 'true-title', 'all', true, "\n" ), + 'apostrophe in handle' => array( "editor's", 'all', false, "\n" ), + 'character reference in handle' => array( 'a&b', 'all', false, "\n" ), + 'true media' => array( 'true-media', true, false, "\n" ), + 'false media' => array( 'false-media', false, false, "\n" ), + ); + } + + /** + * A media value which is not a string, such as the `$args` array of wp_enqueue_script() passed to + * wp_enqueue_style() by mistake, is printed as before rather than causing a fatal error. + * + * @ticket 66342 + * + * @covers WP_Styles::do_item + */ + public function test_array_media_is_printed_as_before() { + $errors = array(); + set_error_handler( + static function ( $errno, $errstr ) use ( &$errors ) { + $errors[] = $errstr; + return true; + }, + E_WARNING | E_NOTICE + ); + + try { + wp_enqueue_style( 'array-media', 'https://example.com/style.css', array(), null, array( 'in_footer' => true ) ); + $markup = get_echo( 'wp_print_styles' ); + } finally { + restore_error_handler(); + } + + $this->assertSame( array( 'Array to string conversion' ), $errors ); + $this->assertSame( "\n", $markup ); + } + + /** + * A right-to-left URL which escaping empties is printed as an empty `href`, as before, without being escaped again. + * + * @ticket 66342 + * + * @covers WP_Styles::do_item + * @covers ::_wp_get_legacy_style_link_tag + */ + public function test_rtl_style_url_emptied_by_escaping_is_printed_empty_and_escaped_once() { + global $wp_styles; + + $wp_styles->text_direction = 'rtl'; + + $escaped_urls = array(); + add_filter( + 'clean_url', + static function ( $url ) use ( &$escaped_urls ) { + $escaped_urls[] = $url; + return str_contains( $url, '-rtl.css' ) ? '' : $url; + } + ); + + wp_enqueue_style( 'blocked', 'https://example.com/style.css', array(), null ); + wp_style_add_data( 'blocked', 'rtl', true ); + + $this->assertSame( + "\n" . + "\n", + get_echo( 'wp_print_styles' ) + ); + $this->assertSame( + array( 'https://example.com/style.css', 'https://example.com/style-rtl.css' ), + $escaped_urls, + 'Each URL should be escaped once.' + ); + } + + /** + * @ticket 66342 + * + * @covers WP_Styles::do_item + * @covers ::_wp_get_legacy_style_link_tag + */ + public function test_style_attributes_are_printed_after_core_attributes() { + $filter = static function ( $attributes ) { + $attributes['crossorigin'] = 'anonymous'; + $attributes['fetchpriority'] = 'high'; + return $attributes; + }; + + add_filter( 'wp_style_attributes', $filter ); + + wp_enqueue_style( 'ordered', 'https://example.com/style.css', array(), null ); + $markup = get_echo( 'wp_print_styles' ); + + $this->assertSame( + "\n", + $markup + ); + } + + /** + * Scripts such as the user profile screen's color scheme picker locate a stylesheet by its ID. + * + * The notice names the method which printed the tag. + * + * @ticket 66342 + * + * @covers WP_Styles::do_item + * @covers ::_wp_filter_style_link_attributes + */ + public function test_style_attributes_filter_cannot_change_enqueued_style_id() { + $this->setExpectedIncorrectUsage( 'WP_Styles::do_item' ); + + $filter = static function ( $attributes ) { + $attributes['id'] = 'changed'; + return $attributes; + }; + + add_filter( 'wp_style_attributes', $filter ); + + wp_enqueue_style( 'scheme', 'https://example.com/scheme.css', array(), null ); + $markup = get_echo( 'wp_print_styles' ); + + $this->assertSame( + "\n", + $markup + ); + } + + /** + * An `href` changed by the wp_style_attributes filter is escaped, while the style_loader_tag filter + * is still passed the URL from before that filter. + * + * @ticket 66342 + * + * @covers WP_Styles::do_item + * @covers ::_wp_get_legacy_style_link_tag + */ + public function test_style_attributes_filter_can_change_href() { + $filter = static function ( $attributes ) { + $attributes['href'] = 'https://cdn.example.com/style.css?a=1&b=2"onload="alert(1)'; + return $attributes; + }; + + add_filter( 'wp_style_attributes', $filter ); + $legacy_filter = new MockAction(); + add_filter( 'style_loader_tag', array( $legacy_filter, 'filter' ), 10, 4 ); + + wp_enqueue_style( 'cdn', 'https://example.com/style.css?a=1&b=2', array(), null ); + $markup = get_echo( 'wp_print_styles' ); + + $this->assertSame( + "\n", + $markup + ); + $this->assertSame( + array( 'https://example.com/style.css?a=1&b=2' ), + array_column( $legacy_filter->get_args(), 2 ), + 'The style_loader_tag filter should be passed the escaped URL from before the wp_style_attributes filter.' + ); + } + + /** + * An `href` which the wp_style_attributes filter changes to a URL with a disallowed protocol is + * left out, without leaving an extra space in the markup passed to the style_loader_tag filter. + * + * @ticket 66342 + * + * @covers WP_Styles::do_item + * @covers ::_wp_build_style_link_tag + */ + public function test_style_attributes_filter_changing_href_to_disallowed_protocol_omits_it() { + add_filter( + 'wp_style_attributes', + static function ( array $attributes ): array { + $attributes['href'] = 'javascript:alert(1)'; + return $attributes; + } + ); + + wp_enqueue_style( 'disallowed', 'https://example.com/style.css', array(), null ); + + $this->assertSame( + "\n", + get_echo( 'wp_print_styles' ) + ); + } + + /** + * A double quote from a filtered escaping function must not end the `href` attribute's value. + * + * @ticket 66342 + * + * @covers WP_Styles::do_item + * @covers ::_wp_get_legacy_style_link_tag + */ + public function test_double_quote_from_clean_url_filter_stays_in_href() { + $filter = static function ( $url ) { + return $url . '"onload="alert(1)'; + }; + + add_filter( 'clean_url', $filter ); + + wp_enqueue_style( 'quoted-url', 'https://example.com/style.css', array(), null ); + $markup = get_echo( 'wp_print_styles' ); + + $this->assertSame( + "\n", + $markup + ); + } + + /** + * A double quote from a filtered escaping function must not end the `href` attribute's value + * in the tag printed by wp_admin_css(), whose URL was printed in single quotes before. + * + * @ticket 66342 + * + * @covers ::wp_admin_css + * @covers ::_wp_get_legacy_style_link_tag + */ + public function test_double_quote_from_clean_url_filter_stays_in_wp_admin_css_href() { + add_filter( + 'clean_url', + static function ( $url ) { + return $url . '"onload="alert(1)'; + } + ); + + $markup = get_echo( 'wp_admin_css', array( 'style-link-api-test' ) ); + + $processor = new WP_HTML_Tag_Processor( $markup ); + $this->assertTrue( $processor->next_tag( 'link' ) ); + $this->assertNull( $processor->get_attribute( 'onload' ), 'The double quote should not end the href attribute.' ); + $this->assertStringEndsWith( ""onload="alert(1)' />\n", $markup ); + } + + /** + * @ticket 66342 + * + * @covers ::wp_admin_css + * @covers ::_wp_get_legacy_style_link_tag + */ + public function test_wp_admin_css_markup_is_unchanged() { + $this->assertSame( + "\n", + get_echo( 'wp_admin_css', array( 'style-link-api-test' ) ) + ); + } + /** * @ticket 42804 */ diff --git a/tests/phpunit/tests/dependencies/wpStyleLinkTag.php b/tests/phpunit/tests/dependencies/wpStyleLinkTag.php new file mode 100644 index 0000000000000..47759e9ebd426 --- /dev/null +++ b/tests/phpunit/tests/dependencies/wpStyleLinkTag.php @@ -0,0 +1,308 @@ +assertSame( + "\n", + wp_get_style_link_tag( + array( + 'rel' => 'stylesheet', + 'href' => 'https://example.com/style.css?one=1&two=2', + 'integrity' => 'sha384-example', + 'crossorigin' => true, + ) + ) + ); + } + + /** + * Only the name is printed for a value of `true` or `null`, an empty string is printed as an empty value, + * and an attribute whose value is `false` is omitted. + * + * @ticket 66342 + * + * @covers ::wp_get_style_link_tag + * @covers ::_wp_build_style_link_tag + */ + public function test_get_style_link_tag_attribute_value_types() { + $this->assertSame( + "\n", + wp_get_style_link_tag( + array( + 'enabled' => true, + 'disabled' => false, + 'null-value' => null, + 'empty-value' => '', + 'zero-string' => '0', + ) + ) + ); + } + + /** + * Case-insensitive duplicate attributes should keep the first value. + * + * @ticket 66342 + * + * @covers ::wp_get_style_link_tag + */ + public function test_get_style_link_tag_ignores_duplicate_attributes() { + $this->assertSame( + "\n", + wp_get_style_link_tag( + array( + 'test' => 'first', + 'TEST' => 'second', + 'disabled' => false, + 'DISABLED' => true, + ) + ) + ); + } + + /** + * @ticket 66342 + * + * @covers ::wp_get_style_link_tag + */ + public function test_get_style_link_tag_applies_wp_style_attributes_filter() { + $filter = static function ( $attributes ) { + $attributes['nonce'] = 'filtered-nonce'; + return $attributes; + }; + + add_filter( 'wp_style_attributes', $filter ); + + $tag = wp_get_style_link_tag( array( 'rel' => 'stylesheet' ) ); + + remove_filter( 'wp_style_attributes', $filter ); + + $this->assertSame( + "\n", + $tag + ); + } + + /** + * @ticket 66342 + * + * @covers ::wp_get_style_link_tag + */ + public function test_get_style_link_tag_prints_attributes_in_the_order_given() { + $this->assertSame( + "\n", + wp_get_style_link_tag( + array( + 'rel' => 'preload', + 'as' => 'style', + 'id' => 'test-css', + 'href' => 'https://example.com/style.css?one=1&two=2', + 'media' => 'print', + 'crossorigin' => true, + ) + ) + ); + } + + /** + * @ticket 66342 + * + * @covers ::wp_get_style_link_tag + * + * @expectedIncorrectUsage WP_HTML_Tag_Processor::set_attribute + */ + public function test_get_style_link_tag_rejects_invalid_attribute_names() { + $this->assertSame( + "\n", + wp_get_style_link_tag( + array( + 'rel' => 'stylesheet', + 'onload="alert(1)"' => true, + 'x>