From e7764ce7b771d6877ee68de7ea2f435b129da050 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 1 Nov 2026 09:39:28 -0800 Subject: [PATCH 1/8] Stop printing the obsolete pr attribute in resource hints The `pr` (hint probability) attribute was only ever part of the W3C Resource Hints working drafts. No browser implemented it, and it was removed from that spec in 2018. The HTML Living Standard, which now defines resource hints, has no such attribute on ``, so printing it only produces invalid markup. Remove `pr` from the allowed attributes and from the `wp_resource_hints` filter documentation. The test keeps passing `pr` to assert that it is now dropped. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 4 ++-- tests/phpunit/tests/general/wpResourceHints.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 5d6786db99bc8..6005a94b97320 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -3954,6 +3954,7 @@ function wp_resource_hints() { * @since 4.6.0 * @since 4.7.0 The `$urls` parameter accepts arrays of specific HTML attributes * as its child elements. + * @since 7.3.0 The obsolete `pr` attribute is no longer printed. * * @param array $urls { * Array of resources and their attributes, or URLs to print for resource hints. @@ -3965,7 +3966,6 @@ function wp_resource_hints() { * @type string $as How the browser should treat the resource * (`script`, `style`, `image`, `document`, etc). * @type string $crossorigin Indicates the CORS policy of the specified resource. - * @type float $pr Expected probability that the resource hint will be used. * @type string $type Type of the resource (`text/html`, `text/css`, etc). * } * } @@ -4022,7 +4022,7 @@ function wp_resource_hints() { foreach ( $atts as $attr => $value ) { if ( ! is_scalar( $value ) - || ( ! in_array( $attr, array( 'as', 'crossorigin', 'href', 'pr', 'rel', 'type' ), true ) && ! is_numeric( $attr ) ) + || ( ! in_array( $attr, array( 'as', 'crossorigin', 'href', 'rel', 'type' ), true ) && ! is_numeric( $attr ) ) ) { continue; diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 3b546dc6e254e..9043b9b367d09 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -236,7 +236,7 @@ public function add_malformed_url_unsupported_scheme( $hints, $method ) { */ public function test_custom_attributes() { $expected = "\n" . - "\n" . + "\n" . "\n" . "\n"; @@ -265,7 +265,7 @@ public function add_url_with_attributes( $hints, $method ) { $hints[] = array( 'crossorigin', 'as' => 'image', - 'pr' => 0.5, + 'pr' => 0.5, // Obsolete attribute which should be ignored. 'href' => 'https://example.com/foo.jpeg', ); $hints[] = array( From c413f6128745c1b5234722eae07d1ad8afa1613e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 1 Nov 2026 09:44:07 -0800 Subject: [PATCH 2/8] Map allowed resource hint attributes to whether they can be valueless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the inline attribute allowlist in `wp_resource_hints()` up into an `$allowed_attributes` map, keyed by attribute name, whose value says whether the attribute may be printed without a value. Of the currently allowed attributes, only `crossorigin` can be. This tightens how valueless attributes are handled: * `'crossorigin' => true` now prints a bare `crossorigin` instead of `crossorigin='1'`, and `false` omits it instead of printing `crossorigin=''`. * A list item, as in `array( 'crossorigin', 'href' => … )`, is now printed only when it names an allowed attribute which may be valueless. Previously any string list item was printed as a bare attribute, which let arbitrary attribute names past the allowlist. * `true` or `false` given for an attribute that requires a value is skipped rather than printed as `'1'` or `''`. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 59 +++++++++---- .../phpunit/tests/general/wpResourceHints.php | 85 +++++++++++++++++++ 2 files changed, 129 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 6005a94b97320..eb32b2cb0dccd 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -3945,6 +3945,18 @@ function wp_resource_hints() { 'prerender' => array(), ); + /* + * Attributes which may be printed on a resource hint, mapped to whether the + * attribute may be printed without a value when it is supplied as `true`. + */ + $allowed_attributes = array( + 'as' => false, + 'crossorigin' => true, + 'href' => false, + 'rel' => false, + 'type' => false, + ); + foreach ( $hints as $relation_type => $urls ) { $unique_urls = array(); @@ -3954,19 +3966,23 @@ function wp_resource_hints() { * @since 4.6.0 * @since 4.7.0 The `$urls` parameter accepts arrays of specific HTML attributes * as its child elements. - * @since 7.3.0 The obsolete `pr` attribute is no longer printed. + * @since 7.3.0 The obsolete `pr` attribute is no longer printed. The `crossorigin` + * attribute may be `true` to print it without a value. * * @param array $urls { * Array of resources and their attributes, or URLs to print for resource hints. * * @type array|string ...$0 { - * Array of resource attributes, or a URL string. + * Array of resource attributes, or a URL string. A valueless attribute + * may also be supplied as a list item, as in `array( 'crossorigin' )`. * - * @type string $href URL to include in resource hints. Required. - * @type string $as How the browser should treat the resource - * (`script`, `style`, `image`, `document`, etc). - * @type string $crossorigin Indicates the CORS policy of the specified resource. - * @type string $type Type of the resource (`text/html`, `text/css`, etc). + * @type string $href URL to include in resource hints. Required. + * @type string $as How the browser should treat the resource + * (`script`, `style`, `image`, `document`, etc). + * @type string|bool $crossorigin Indicates the CORS policy of the specified resource. + * When `true`, it is printed without a value, which + * is equivalent to `anonymous`. + * @type string $type Type of the resource (`text/html`, `text/css`, etc). * } * } * @param string $relation_type The relation type the URLs are printed for. One of @@ -4021,20 +4037,33 @@ function wp_resource_hints() { $html = ''; foreach ( $atts as $attr => $value ) { - if ( ! is_scalar( $value ) - || ( ! in_array( $attr, array( 'as', 'crossorigin', 'href', 'rel', 'type' ), true ) && ! is_numeric( $attr ) ) - ) { + // A list item supplies a valueless attribute, as in `array( 'crossorigin', 'href' => … )`. + if ( is_int( $attr ) ) { + if ( ! is_string( $value ) ) { + continue; + } + $attr = $value; + $value = true; + } + if ( ! isset( $allowed_attributes[ $attr ] ) ) { continue; } - $value = ( 'href' === $attr ) ? esc_url( $value ) : esc_attr( $value ); + if ( is_bool( $value ) ) { + if ( $value && $allowed_attributes[ $attr ] ) { + $html .= " $attr"; + } + continue; + } - if ( ! is_string( $attr ) ) { - $html .= " $value"; - } else { - $html .= " $attr='$value'"; + if ( ! is_scalar( $value ) ) { + continue; } + + $value = ( 'href' === $attr ) ? esc_url( (string) $value ) : esc_attr( (string) $value ); + + $html .= " $attr='$value'"; } $html = trim( $html ); diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 9043b9b367d09..0d772d0b1c03c 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -284,4 +284,89 @@ public function add_url_with_attributes( $hints, $method ) { return $hints; } + + /** + * Tests that boolean values only print attributes that may be valueless. + * + * @dataProvider data_boolean_attributes + * + * @param array $hint Resource hint attributes. + * @param string $expected Expected markup. + */ + public function test_boolean_attributes( array $hint, string $expected ) { + add_filter( + 'wp_resource_hints', + static function ( array $hints, string $relation_type ) use ( $hint ): array { + if ( 'prefetch' === $relation_type ) { + $hints[] = $hint; + } + return $hints; + }, + 10, + 2 + ); + + $this->assertSame( $expected, get_echo( 'wp_resource_hints' ) ); + } + + /** + * Data provider. + * + * @return array, expected: string }> + */ + public function data_boolean_attributes(): array { + $href = 'https://example.com/foo.css'; + + return array( + 'crossorigin as true' => array( + 'hint' => array( + 'crossorigin' => true, + 'href' => $href, + ), + 'expected' => "\n", + ), + 'crossorigin as list item' => array( + 'hint' => array( + 'crossorigin', + 'href' => $href, + ), + 'expected' => "\n", + ), + 'crossorigin as false' => array( + 'hint' => array( + 'crossorigin' => false, + 'href' => $href, + ), + 'expected' => "\n", + ), + 'non-boolean as true' => array( + 'hint' => array( + 'as' => true, + 'href' => $href, + ), + 'expected' => "\n", + ), + 'non-boolean as list item' => array( + 'hint' => array( + 'as', + 'href' => $href, + ), + 'expected' => "\n", + ), + 'unknown list item' => array( + 'hint' => array( + 'foo', + 'href' => $href, + ), + 'expected' => "\n", + ), + 'non-string list item' => array( + 'hint' => array( + 1.5, + 'href' => $href, + ), + 'expected' => "\n", + ), + ); + } } From dc433d7b692cacb706cabf6fb2fc2f96dbfee48e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 1 Nov 2026 09:51:20 -0800 Subject: [PATCH 3/8] Allow more spec-defined link attributes on resource hints Add `fetchpriority`, `hreflang`, `media`, `referrerpolicy`, and `title` to the attributes `wp_resource_hints()` prints. They are the remaining attributes of the HTML link element which the spec allows with the dns-prefetch, preconnect, prefetch, and prerender link types. Of these, `fetchpriority` is the closest successor to the removed `pr` attribute, as it lets a hint signal how important its fetch is. The other link attributes stay disallowed, since the spec only permits them with other link types: `blocking` (stylesheet, expect), `color` (mask-icon), `disabled` (stylesheet), `imagesizes` and `imagesrcset` (preload of an image), `integrity` (stylesheet, preload, modulepreload), and `sizes` (icons). Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 43 +++++++++++++------ .../phpunit/tests/general/wpResourceHints.php | 39 +++++++++++++++++ 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index eb32b2cb0dccd..6c02686f8de6f 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -3948,13 +3948,22 @@ function wp_resource_hints() { /* * Attributes which may be printed on a resource hint, mapped to whether the * attribute may be printed without a value when it is supplied as `true`. + * + * Other attributes of the link element are omitted because the HTML spec only + * allows them with other link types: blocking, color, disabled, imagesizes, + * imagesrcset, integrity, and sizes. */ $allowed_attributes = array( - 'as' => false, - 'crossorigin' => true, - 'href' => false, - 'rel' => false, - 'type' => false, + 'as' => false, + 'crossorigin' => true, + 'fetchpriority' => false, + 'href' => false, + 'hreflang' => false, + 'media' => false, + 'referrerpolicy' => false, + 'rel' => false, + 'title' => false, + 'type' => false, ); foreach ( $hints as $relation_type => $urls ) { @@ -3967,7 +3976,9 @@ function wp_resource_hints() { * @since 4.7.0 The `$urls` parameter accepts arrays of specific HTML attributes * as its child elements. * @since 7.3.0 The obsolete `pr` attribute is no longer printed. The `crossorigin` - * attribute may be `true` to print it without a value. + * attribute may be `true` to print it without a value. Added the + * `fetchpriority`, `hreflang`, `media`, `referrerpolicy`, and `title` + * attributes. * * @param array $urls { * Array of resources and their attributes, or URLs to print for resource hints. @@ -3976,13 +3987,19 @@ function wp_resource_hints() { * Array of resource attributes, or a URL string. A valueless attribute * may also be supplied as a list item, as in `array( 'crossorigin' )`. * - * @type string $href URL to include in resource hints. Required. - * @type string $as How the browser should treat the resource - * (`script`, `style`, `image`, `document`, etc). - * @type string|bool $crossorigin Indicates the CORS policy of the specified resource. - * When `true`, it is printed without a value, which - * is equivalent to `anonymous`. - * @type string $type Type of the resource (`text/html`, `text/css`, etc). + * @type string $href URL to include in resource hints. Required. + * @type string $as How the browser should treat the resource + * (`script`, `style`, `image`, `document`, etc). + * @type string|bool $crossorigin Indicates the CORS policy of the specified resource. + * When `true`, it is printed without a value, which + * is equivalent to `anonymous`. + * @type string $fetchpriority Priority of the fetch relative to other resources + * (`high`, `low`, or `auto`). + * @type string $hreflang Language of the resource. + * @type string $media Media query the resource applies to. + * @type string $referrerpolicy Referrer policy for the fetch. + * @type string $title Title of the resource hint. + * @type string $type Type of the resource (`text/html`, `text/css`, etc). * } * } * @param string $relation_type The relation type the URLs are printed for. One of diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 0d772d0b1c03c..7b8ea8408d8e3 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -285,6 +285,45 @@ public function add_url_with_attributes( $hints, $method ) { return $hints; } + /** + * Tests that link attributes allowed on resource hints are printed, and that + * those the HTML spec restricts to other link types are not. + */ + public function test_spec_attributes() { + add_filter( + 'wp_resource_hints', + static function ( array $hints, string $relation_type ): array { + if ( 'prefetch' === $relation_type ) { + $hints[] = array( + 'href' => 'https://example.com/foo.html', + 'as' => 'document', + 'fetchpriority' => 'low', + 'hreflang' => 'en', + 'media' => '(min-width: 600px)', + 'referrerpolicy' => 'no-referrer', + 'title' => 'Foo', + 'type' => 'text/html', + 'blocking' => 'render', + 'color' => '#000', + 'imagesizes' => '100vw', + 'imagesrcset' => 'https://example.com/foo.jpeg 1x', + 'integrity' => 'sha384-foo', + 'sizes' => 'any', + 'disabled', + ); + } + return $hints; + }, + 10, + 2 + ); + + $this->assertSame( + "\n", + get_echo( 'wp_resource_hints' ) + ); + } + /** * Tests that boolean values only print attributes that may be valueless. * From db9db3f2963aef7cf28d3e7bc7b0ed081b4db9d4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 1 Nov 2026 13:22:30 -0800 Subject: [PATCH 4/8] Tag the resource hint attribute tests with their ticket Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- tests/phpunit/tests/general/wpResourceHints.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 7b8ea8408d8e3..ccf4760dccf22 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -233,6 +233,7 @@ public function add_malformed_url_unsupported_scheme( $hints, $method ) { /** * @ticket 38121 + * @ticket 66435 */ public function test_custom_attributes() { $expected = "\n" . @@ -288,6 +289,8 @@ public function add_url_with_attributes( $hints, $method ) { /** * Tests that link attributes allowed on resource hints are printed, and that * those the HTML spec restricts to other link types are not. + * + * @ticket 66435 */ public function test_spec_attributes() { add_filter( @@ -327,6 +330,8 @@ static function ( array $hints, string $relation_type ): array { /** * Tests that boolean values only print attributes that may be valueless. * + * @ticket 66435 + * * @dataProvider data_boolean_attributes * * @param array $hint Resource hint attributes. From 79b7c150dfbc2f48efb446c843b86466a031c0ca Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 1 Nov 2026 21:38:38 -0800 Subject: [PATCH 5/8] Compare resource hint markup with assertEqualHTML The resource hint tests compared the printed `` tags as exact strings, so they also asserted incidental details such as attribute order and quoting style. Use `assertEqualHTML()` so they assert only that the markup is equivalent HTML. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- .../phpunit/tests/general/wpResourceHints.php | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index ccf4760dccf22..29890a0ac2033 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -41,7 +41,7 @@ public function test_dns_prefetching() { remove_filter( 'wp_resource_hints', array( $this, 'add_dns_prefetch_domains' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function add_dns_prefetch_domains( $hints, $method ) { @@ -72,7 +72,7 @@ public function test_preconnect() { remove_filter( 'wp_resource_hints', array( $this, 'add_preconnect_domains' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function add_preconnect_domains( $hints, $method ) { @@ -98,7 +98,7 @@ public function test_prerender() { remove_filter( 'wp_resource_hints', array( $this, 'add_prerender_urls' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function add_prerender_urls( $hints, $method ) { @@ -121,7 +121,7 @@ public function test_parse_url_dns_prefetch() { remove_filter( 'wp_resource_hints', array( $this, 'add_dns_prefetch_long_urls' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function add_dns_prefetch_long_urls( $hints, $method ) { @@ -146,7 +146,7 @@ public function test_dns_prefetch_styles() { wp_dequeue_style( 'googlefonts' ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function test_dns_prefetch_scripts() { @@ -163,7 +163,7 @@ public function test_dns_prefetch_scripts() { wp_dequeue_style( 'googlefonts' ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } /** @@ -179,7 +179,7 @@ public function test_dns_prefetch_scripts_does_not_include_registered_only() { wp_deregister_script( 'jquery-elsewhere' ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); $this->assertStringNotContainsString( $unexpected, $actual ); } @@ -193,7 +193,7 @@ public function test_deregistered_scripts_are_ignored() { wp_deregister_script( 'test-script' ); $actual = get_echo( 'wp_resource_hints' ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } /** @@ -206,13 +206,13 @@ public function test_malformed_urls() { add_filter( 'wp_resource_hints', array( $this, 'add_malformed_url_errant_colon' ), 10, 2 ); $actual = get_echo( 'wp_resource_hints' ); remove_filter( 'wp_resource_hints', array( $this, 'add_malformed_url_errant_colon' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); // Unsupported Scheme. add_filter( 'wp_resource_hints', array( $this, 'add_malformed_url_unsupported_scheme' ), 10, 2 ); $actual = get_echo( 'wp_resource_hints' ); remove_filter( 'wp_resource_hints', array( $this, 'add_malformed_url_unsupported_scheme' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function add_malformed_url_errant_colon( $hints, $method ) { @@ -247,7 +247,7 @@ public function test_custom_attributes() { remove_filter( 'wp_resource_hints', array( $this, 'add_url_with_attributes' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function add_url_with_attributes( $hints, $method ) { @@ -321,7 +321,7 @@ static function ( array $hints, string $relation_type ): array { 2 ); - $this->assertSame( + $this->assertEqualHTML( "\n", get_echo( 'wp_resource_hints' ) ); @@ -350,7 +350,7 @@ static function ( array $hints, string $relation_type ) use ( $hint ): array { 2 ); - $this->assertSame( $expected, get_echo( 'wp_resource_hints' ) ); + $this->assertEqualHTML( $expected, get_echo( 'wp_resource_hints' ) ); } /** From 33ea5059ee84c7fb7757c4a26bfa77600df9e98c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 1 Nov 2026 22:06:13 -0800 Subject: [PATCH 6/8] Link the boolean attributes data provider to its test Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- tests/phpunit/tests/general/wpResourceHints.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 29890a0ac2033..0309fb80f50e8 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -354,7 +354,7 @@ static function ( array $hints, string $relation_type ) use ( $hint ): array { } /** - * Data provider. + * Data provider for {@see self::test_boolean_attributes()}. * * @return array, expected: string }> */ From 2addb9a910a57c7c377f718440a0799148948502 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 4 Nov 2026 00:17:25 -0700 Subject: [PATCH 7/8] Build resource hint tags with the HTML Tag Processor Constructing the `` markup by hand meant escaping each value and printing valueless attributes manually. `WP_HTML_Tag_Processor::set_attribute()` handles both: it escapes values (running URL attributes like `href` through `esc_url()`), and it prints `true` as a bare attribute. The allowlist still decides which attributes may be printed and which may be valueless. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 6c02686f8de6f..a4a9fd5efd55d 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -4051,7 +4051,8 @@ function wp_resource_hints() { } foreach ( $unique_urls as $atts ) { - $html = ''; + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); foreach ( $atts as $attr => $value ) { // A list item supplies a valueless attribute, as in `array( 'crossorigin', 'href' => … )`. @@ -4068,24 +4069,19 @@ function wp_resource_hints() { } if ( is_bool( $value ) ) { - if ( $value && $allowed_attributes[ $attr ] ) { - $html .= " $attr"; + if ( ! $allowed_attributes[ $attr ] ) { + continue; } + } elseif ( is_scalar( $value ) ) { + $value = (string) $value; + } else { continue; } - if ( ! is_scalar( $value ) ) { - continue; - } - - $value = ( 'href' === $attr ) ? esc_url( (string) $value ) : esc_attr( (string) $value ); - - $html .= " $attr='$value'"; + $processor->set_attribute( $attr, $value ); } - $html = trim( $html ); - - echo "\n"; + echo $processor->get_updated_html() . "\n"; } } } From 016be5fabc74eb596792007e5cd398efdbf62564 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 4 Nov 2026 00:21:29 -0700 Subject: [PATCH 8/8] Build preload tags with the HTML Tag Processor Like `wp_resource_hints()`, `wp_preload_resources()` assembled its `` markup by hand. Build it with `WP_HTML_Tag_Processor` instead, so attribute values are escaped and valueless attributes are printed by `set_attribute()`. The `href` is still limited to the `http` and `https` protocols. The attribute allowlist becomes an `$allowed_attributes` map recording whether each attribute may be valueless, as in `wp_resource_hints()`. This fixes two cases: * `'crossorigin' => true` printed `crossorigin='1'`, and now prints a bare `crossorigin`. * Any list item was printed as a bare attribute, unchecked against the allowlist. Now only `crossorigin` is. The tests now compare markup with `assertEqualHTML()` and cover these cases. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 83 +++++++++++----- .../tests/general/wpPreloadResources.php | 95 ++++++++++++++++++- 2 files changed, 150 insertions(+), 28 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index cebeae2aa6884..321414eeb7bbc 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -4108,23 +4108,28 @@ function wp_preload_resources() { * * @since 6.1.0 * @since 6.6.0 Added the `$fetchpriority` attribute. + * @since 7.3.0 The `crossorigin` attribute may be `true` to print it without a value. + * List items other than `crossorigin` are no longer printed. * * @param array $preload_resources { * Array of resources and their attributes, or URLs to print for resource preloads. * * @type array ...$0 { - * Array of resource attributes. - * - * @type string $href URL to include in resource preloads. May be omitted - * for an image preloaded by its `$imagesrcset`. - * @type string $as How the browser should treat the resource - * (`script`, `style`, `image`, `document`, etc). - * @type string $crossorigin Indicates the CORS policy of the specified resource. - * @type string $type Type of the resource (`text/html`, `text/css`, etc). - * @type string $media Accepts media types or media queries. Allows responsive preloading. - * @type string $imagesizes Responsive source size to the source Set. - * @type string $imagesrcset Responsive image sources to the source set. - * @type string $fetchpriority Fetchpriority value for the resource. + * Array of resource attributes. A valueless attribute may also be supplied + * as a list item, as in `array( 'crossorigin' )`. + * + * @type string $href URL to include in resource preloads. May be omitted + * for an image preloaded by its `$imagesrcset`. + * @type string $as How the browser should treat the resource + * (`script`, `style`, `image`, `document`, etc). + * @type string|bool $crossorigin Indicates the CORS policy of the specified resource. + * When `true`, it is printed without a value, which + * is equivalent to `anonymous`. + * @type string $type Type of the resource (`text/html`, `text/css`, etc). + * @type string $media Accepts media types or media queries. Allows responsive preloading. + * @type string $imagesizes Responsive source size to the source Set. + * @type string $imagesrcset Responsive image sources to the source set. + * @type string $fetchpriority Fetchpriority value for the resource. * } * } */ @@ -4160,18 +4165,49 @@ function wp_preload_resources() { } } + /* + * Attributes which may be printed on a preload, mapped to whether the + * attribute may be printed without a value when it is supplied as `true`. + */ + $allowed_attributes = array( + 'as' => false, + 'crossorigin' => true, + 'fetchpriority' => false, + 'href' => false, + 'imagesizes' => false, + 'imagesrcset' => false, + 'media' => false, + 'type' => false, + ); + // Build and output the HTML for each unique resource. foreach ( $unique_resources as $unique_resource ) { - $html = ''; + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); + $processor->set_attribute( 'rel', 'preload' ); foreach ( $unique_resource as $resource_key => $resource_value ) { - if ( ! is_scalar( $resource_value ) ) { - continue; + // A list item supplies a valueless attribute, as in `array( 'crossorigin', 'href' => … )`. + if ( is_int( $resource_key ) ) { + if ( ! is_string( $resource_value ) ) { + continue; + } + $resource_key = $resource_value; + $resource_value = true; } // Ignore non-supported attributes. - $non_supported_attributes = array( 'as', 'crossorigin', 'href', 'imagesrcset', 'imagesizes', 'type', 'media', 'fetchpriority' ); - if ( ! in_array( $resource_key, $non_supported_attributes, true ) && ! is_numeric( $resource_key ) ) { + if ( ! isset( $allowed_attributes[ $resource_key ] ) ) { + continue; + } + + if ( is_bool( $resource_value ) ) { + if ( ! $allowed_attributes[ $resource_key ] ) { + continue; + } + } elseif ( is_scalar( $resource_value ) ) { + $resource_value = (string) $resource_value; + } else { continue; } @@ -4187,17 +4223,14 @@ function wp_preload_resources() { continue; } - $resource_value = ( 'href' === $resource_key ) ? esc_url( $resource_value, array( 'http', 'https' ) ) : esc_attr( $resource_value ); - - if ( ! is_string( $resource_key ) ) { - $html .= " $resource_value"; - } else { - $html .= " $resource_key='$resource_value'"; + if ( 'href' === $resource_key && is_string( $resource_value ) ) { + $resource_value = sanitize_url( $resource_value, array( 'http', 'https' ) ); } + + $processor->set_attribute( $resource_key, $resource_value ); } - $html = trim( $html ); - printf( "\n", $html ); + echo $processor->get_updated_html() . "\n"; } } diff --git a/tests/phpunit/tests/general/wpPreloadResources.php b/tests/phpunit/tests/general/wpPreloadResources.php index dbf632ea2709b..cacc03e8f28ac 100644 --- a/tests/phpunit/tests/general/wpPreloadResources.php +++ b/tests/phpunit/tests/general/wpPreloadResources.php @@ -12,8 +12,11 @@ class Tests_General_wpPreloadResources extends WP_UnitTestCase { * @dataProvider data_preload_resources * * @ticket 42438 + * + * @param string $expected Expected markup. + * @param array> $preload_resources Resources to preload. */ - public function test_preload_resources( $expected, $preload_resources ) { + public function test_preload_resources( string $expected, array $preload_resources ) { $callback = static function () use ( $preload_resources ) { return $preload_resources; }; @@ -22,11 +25,11 @@ public function test_preload_resources( $expected, $preload_resources ) { $actual = get_echo( 'wp_preload_resources' ); remove_filter( 'wp_preload_resources', $callback ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } /** - * Test provider for all preload link possible combinations. + * Data provider for {@see self::test_preload_resources()}. * * @return array[] */ @@ -286,4 +289,90 @@ public static function data_preload_resources() { ), ); } + + /** + * Tests that boolean attribute values and list items are only printed when valueless. + * + * @ticket 66435 + * + * @dataProvider data_boolean_attributes + * + * @param string $expected Expected markup. + * @param array $preload_resource Resource to preload. + */ + public function test_boolean_attributes( string $expected, array $preload_resource ) { + add_filter( + 'wp_preload_resources', + static function () use ( $preload_resource ): array { + return array( $preload_resource ); + } + ); + + $this->assertEqualHTML( $expected, get_echo( 'wp_preload_resources' ) ); + } + + /** + * Data provider for {@see self::test_boolean_attributes()}. + * + * @return array }> + */ + public static function data_boolean_attributes(): array { + return array( + 'crossorigin true' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'crossorigin' => true, + ), + ), + 'crossorigin false' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'crossorigin' => false, + ), + ), + 'non-boolean attribute true' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + 'type' => true, + ), + ), + 'unsupported list item' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + 'async', + ), + ), + 'non-string list item' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + true, + ), + ), + 'escaped attribute value' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/style.css', + 'as' => 'style', + 'media' => 'screen and (min-width: 600px)" onload="alert(1)', + ), + ), + 'unsafe href protocol removed' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'javascript:alert(1)', + 'as' => 'script', + ), + ), + ); + } }