From f681caf17a07157377cb14d4e494fbfdd50d6b99 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 22:45:03 +0200 Subject: [PATCH 01/16] Fix decoded HTML attribute prefix matching --- src/wp-includes/html-api/class-wp-html-decoder.php | 14 ++++++++++---- tests/phpunit/tests/html-api/wpHtmlDecoder.php | 5 +++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-decoder.php b/src/wp-includes/html-api/class-wp-html-decoder.php index d14009d3d9fb8..439ab83190d63 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -60,17 +60,23 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen continue; } - // If there is a character reference, then the decoded value must exactly match what follows in the search string. - if ( 0 !== substr_compare( $search_text, $next_chunk, $search_at, strlen( $next_chunk ), $loose_case ) ) { + /* + * If there is a character reference, then the decoded value must + * match what follows in the search string. The search string may + * end within a multi-code-point replacement, such as `<⃒` + * decoding to `<⃒`, and still be a prefix match. + */ + $match_length = min( strlen( $next_chunk ), $search_length - $search_at ); + if ( 0 !== substr_compare( $search_text, $next_chunk, $search_at, $match_length, $loose_case ) ) { return false; } // The character reference matched, so continue checking. $haystack_at += $token_length; - $search_at += strlen( $next_chunk ); + $search_at += $match_length; } - return true; + return $search_at === $search_length; } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlDecoder.php b/tests/phpunit/tests/html-api/wpHtmlDecoder.php index 97954f4eb3e30..bbbd94fc09d12 100644 --- a/tests/phpunit/tests/html-api/wpHtmlDecoder.php +++ b/tests/phpunit/tests/html-api/wpHtmlDecoder.php @@ -161,6 +161,11 @@ public static function data_attributes_with_prefix_and_case_sensitive_match() { array( 'http://wordpress.org', 'Http', 'ascii-case-insensitive', true ), array( 'http://wordpress.org', 'https', 'case-sensitive', false ), array( 'http://wordpress.org', 'https', 'ascii-case-insensitive', false ), + array( '', 'http', 'case-sensitive', false ), + array( 'jav', 'javascript:', 'case-sensitive', false ), + array( 'jav', 'javascript:', 'ascii-case-insensitive', false ), + array( '<⃒script', '<', 'case-sensitive', true ), + array( '>⃒script', '>', 'case-sensitive', true ), ); } } From 79fe10093646d967b7c90eb28d959202fd938249 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 1 Jul 2026 14:21:18 +0200 Subject: [PATCH 02/16] Test method types and documentation --- tests/phpunit/tests/html-api/wpHtmlDecoder.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlDecoder.php b/tests/phpunit/tests/html-api/wpHtmlDecoder.php index 1baa757c74d7b..e33f34091d98b 100644 --- a/tests/phpunit/tests/html-api/wpHtmlDecoder.php +++ b/tests/phpunit/tests/html-api/wpHtmlDecoder.php @@ -280,6 +280,7 @@ public static function data_case_variants_of_attribute_prefixes() { * Ensures that `attribute_starts_with` respects the case sensitivity argument. * * @ticket 61072 + * @ticket 65372 * * @dataProvider data_attributes_with_prefix_and_case_sensitive_match * @@ -290,16 +291,16 @@ public static function data_case_variants_of_attribute_prefixes() { * @param bool $is_match Whether the search string is a prefix for the attribute value, * given the case sensitivity setting. */ - public function test_attribute_starts_with_heeds_case_sensitivity( $attribute_value, $search_string, $case_sensitivity, $is_match ) { + public function test_attribute_starts_with_heeds_case_sensitivity( string $attribute_value, string $search_string, string $case_sensitivity, bool $is_match ): void { if ( $is_match ) { $this->assertTrue( WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, $case_sensitivity ), - 'Should have found attribute prefix with case-sensitive search.' + "Should have found attribute prefix with {$case_sensitivity} search." ); } else { $this->assertFalse( WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, $case_sensitivity ), - 'Should not have matched attribute with prefix with ASCII-case-insensitive search.' + "Should not have matched attribute with prefix with {$case_sensitivity} search." ); } } @@ -307,9 +308,9 @@ public function test_attribute_starts_with_heeds_case_sensitivity( $attribute_va /** * Data provider. * - * @return array[]. + * @return array. */ - public static function data_attributes_with_prefix_and_case_sensitive_match() { + public static function data_attributes_with_prefix_and_case_sensitive_match(): array { return array( array( 'http://wordpress.org', 'http', 'case-sensitive', true ), array( 'http://wordpress.org', 'http', 'ascii-case-insensitive', true ), From 23902f7c44e42c79889c75dd405a20309f6ab0fe Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 1 Jul 2026 15:22:55 +0200 Subject: [PATCH 03/16] Move to separate tests --- .../phpunit/tests/html-api/wpHtmlDecoder.php | 61 ++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlDecoder.php b/tests/phpunit/tests/html-api/wpHtmlDecoder.php index e33f34091d98b..05fd145990584 100644 --- a/tests/phpunit/tests/html-api/wpHtmlDecoder.php +++ b/tests/phpunit/tests/html-api/wpHtmlDecoder.php @@ -276,11 +276,63 @@ public static function data_case_variants_of_attribute_prefixes() { } } + /** + * Ensures that `attribute_starts_with` checks the full search string. + * + * @ticket 65372 + * + * @dataProvider data_attribute_starts_with_search_string_boundaries + * + * @param string $attribute_value Raw attribute value from HTML string. + * @param string $search_string Prefix contained or not contained in encoded attribute value. + * @param bool $is_match Whether the search string is a prefix for the attribute value + */ + public function test_attribute_starts_with_checks_search_string_boundaries( + string $attribute_value, + string $search_string, + bool $is_match + ): void { + if ( $is_match ) { + $this->assertTrue( + WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, 'case-sensitive' ), + 'Should have matched attribute prefix.' + ); + } else { + $this->assertFalse( + WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, 'case-sensitive' ), + 'Should not have matched attribute with prefix.' + ); + } + } + + /** + * Data provider. + * + * @return Generator Test cases. + */ + public static function data_attribute_starts_with_search_string_boundaries(): Generator { + yield 'Empty attribute does not match non-empty prefix' => array( '', 'http', false ); + yield 'Short attribute does not match longer prefix' => array( + 'jav', + 'javascript:', + false, + ); + yield "2-byte fj (\x66\x6a) starts with f" => array( + 'fj is literally fj', + 'f', + true, + ); + yield "2-byte <⃒ (<⃒) starts with '<'" => array( + '<⃒script>', + '<', + true, + ); + } + /** * Ensures that `attribute_starts_with` respects the case sensitivity argument. * * @ticket 61072 - * @ticket 65372 * * @dataProvider data_attributes_with_prefix_and_case_sensitive_match * @@ -291,7 +343,7 @@ public static function data_case_variants_of_attribute_prefixes() { * @param bool $is_match Whether the search string is a prefix for the attribute value, * given the case sensitivity setting. */ - public function test_attribute_starts_with_heeds_case_sensitivity( string $attribute_value, string $search_string, string $case_sensitivity, bool $is_match ): void { + public function test_attribute_starts_with_heeds_case_sensitivity( $attribute_value, $search_string, $case_sensitivity, $is_match ) { if ( $is_match ) { $this->assertTrue( WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, $case_sensitivity ), @@ -320,11 +372,6 @@ public static function data_attributes_with_prefix_and_case_sensitive_match(): a array( 'http://wordpress.org', 'Http', 'ascii-case-insensitive', true ), array( 'http://wordpress.org', 'https', 'case-sensitive', false ), array( 'http://wordpress.org', 'https', 'ascii-case-insensitive', false ), - array( '', 'http', 'case-sensitive', false ), - array( 'jav', 'javascript:', 'case-sensitive', false ), - array( 'jav', 'javascript:', 'ascii-case-insensitive', false ), - array( '<⃒script', '<', 'case-sensitive', true ), - array( '>⃒script', '>', 'case-sensitive', true ), ); } } From 4c1307b19470a48e2363acb196281de47e841555 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 1 Jul 2026 15:29:55 +0200 Subject: [PATCH 04/16] Fix minor typo (characters do match) --- src/wp-includes/html-api/class-wp-html-decoder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-html-decoder.php b/src/wp-includes/html-api/class-wp-html-decoder.php index b000adc89c68e..f124c8d3e47eb 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -53,7 +53,7 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen return false; } - // If there's no character reference but the character do match, then it could still match. + // If there's no character reference but the characters do match, then it could still match. if ( null === $next_chunk && $chars_match ) { ++$haystack_at; ++$search_at; From 8d73046c0d3b31bb268708f46c1853e46ab90e4d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 1 Jul 2026 17:42:14 +0200 Subject: [PATCH 05/16] Simplify comment, add combining mark tests --- src/wp-includes/html-api/class-wp-html-decoder.php | 7 +------ tests/phpunit/tests/html-api/wpHtmlDecoder.php | 12 +++++++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-decoder.php b/src/wp-includes/html-api/class-wp-html-decoder.php index f124c8d3e47eb..569086b755201 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -60,12 +60,7 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen continue; } - /* - * If there is a character reference, then the decoded value must - * match what follows in the search string. The search string may - * end within a multi-code-point replacement, such as `<⃒` - * decoding to `<⃒`, and still be a prefix match. - */ + // If there is a character reference, then the decoded value must exactly match what follows in the search string. $match_length = min( strlen( $next_chunk ), $search_length - $search_at ); if ( 0 !== substr_compare( $search_text, $next_chunk, $search_at, $match_length, $loose_case ) ) { return false; diff --git a/tests/phpunit/tests/html-api/wpHtmlDecoder.php b/tests/phpunit/tests/html-api/wpHtmlDecoder.php index 05fd145990584..65296c472767f 100644 --- a/tests/phpunit/tests/html-api/wpHtmlDecoder.php +++ b/tests/phpunit/tests/html-api/wpHtmlDecoder.php @@ -318,7 +318,7 @@ public static function data_attribute_starts_with_search_string_boundaries(): Ge false, ); yield "2-byte fj (\x66\x6a) starts with f" => array( - 'fj is literally fj', + 'fj is literally "f" followed by "j"', 'f', true, ); @@ -327,6 +327,16 @@ public static function data_attribute_starts_with_search_string_boundaries(): Ge '<', true, ); + yield "Combining character references (¬̸) full match on '¬̸' prefix" => array( + '¬̸ A negated not?', + '¬̸', + true, + ); + yield "Combining character references (¬̸) partial match on '¬' prefix" => array( + '¬&$#x0338; A negated not?', + '¬', + true, + ); } /** From c262a3eeee983d34ce773a341d97e87390f652b6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 1 Jul 2026 17:48:42 +0200 Subject: [PATCH 06/16] Improve test cases and param description --- tests/phpunit/tests/html-api/wpHtmlDecoder.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlDecoder.php b/tests/phpunit/tests/html-api/wpHtmlDecoder.php index 65296c472767f..d13249eaf0366 100644 --- a/tests/phpunit/tests/html-api/wpHtmlDecoder.php +++ b/tests/phpunit/tests/html-api/wpHtmlDecoder.php @@ -285,7 +285,7 @@ public static function data_case_variants_of_attribute_prefixes() { * * @param string $attribute_value Raw attribute value from HTML string. * @param string $search_string Prefix contained or not contained in encoded attribute value. - * @param bool $is_match Whether the search string is a prefix for the attribute value + * @param bool $is_match Whether the search string is a prefix for the attribute value. */ public function test_attribute_starts_with_checks_search_string_boundaries( string $attribute_value, @@ -313,16 +313,21 @@ public function test_attribute_starts_with_checks_search_string_boundaries( public static function data_attribute_starts_with_search_string_boundaries(): Generator { yield 'Empty attribute does not match non-empty prefix' => array( '', 'http', false ); yield 'Short attribute does not match longer prefix' => array( - 'jav', - 'javascript:', + 'java', + 'javascript', false, ); - yield "2-byte fj (\x66\x6a) starts with f" => array( + yield 'Longer attribute matches shorter prefix' => array( + 'javascript', + 'java', + true, + ); + yield "fj (decodes to 2-byte 'fj') starts with f" => array( 'fj is literally "f" followed by "j"', 'f', true, ); - yield "2-byte <⃒ (<⃒) starts with '<'" => array( + yield "<⃒ (decodes to 2-byte '<⃒') starts with '<'" => array( '<⃒script>', '<', true, From 3f031678f644228d069459e75c0566a90de185b6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 1 Jul 2026 18:02:20 +0200 Subject: [PATCH 07/16] revert unrelated changes --- tests/phpunit/tests/html-api/wpHtmlDecoder.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlDecoder.php b/tests/phpunit/tests/html-api/wpHtmlDecoder.php index d13249eaf0366..1be4aac908318 100644 --- a/tests/phpunit/tests/html-api/wpHtmlDecoder.php +++ b/tests/phpunit/tests/html-api/wpHtmlDecoder.php @@ -362,12 +362,12 @@ public function test_attribute_starts_with_heeds_case_sensitivity( $attribute_va if ( $is_match ) { $this->assertTrue( WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, $case_sensitivity ), - "Should have found attribute prefix with {$case_sensitivity} search." + 'Should have found attribute prefix with case-sensitive search.' ); } else { $this->assertFalse( WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, $case_sensitivity ), - "Should not have matched attribute with prefix with {$case_sensitivity} search." + 'Should not have matched attribute with prefix with ASCII-case-insensitive search.' ); } } @@ -375,9 +375,9 @@ public function test_attribute_starts_with_heeds_case_sensitivity( $attribute_va /** * Data provider. * - * @return array. + * @return array[]. */ - public static function data_attributes_with_prefix_and_case_sensitive_match(): array { + public static function data_attributes_with_prefix_and_case_sensitive_match() { return array( array( 'http://wordpress.org', 'http', 'case-sensitive', true ), array( 'http://wordpress.org', 'http', 'ascii-case-insensitive', true ), From 2f1238c3316b1bee6ed75bd24dab1b52a9705de1 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 8 Jul 2026 13:51:14 +0200 Subject: [PATCH 08/16] Correct "2-byte" to "2-codepoint" --- tests/phpunit/tests/html-api/wpHtmlDecoder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlDecoder.php b/tests/phpunit/tests/html-api/wpHtmlDecoder.php index 5e84c281f50a0..48504901ab95f 100644 --- a/tests/phpunit/tests/html-api/wpHtmlDecoder.php +++ b/tests/phpunit/tests/html-api/wpHtmlDecoder.php @@ -392,12 +392,12 @@ public static function data_attribute_starts_with_search_string_boundaries(): Ge 'java', true, ); - yield "fj (decodes to 2-byte 'fj') starts with f" => array( + yield "fj (decodes to 2-codepoint 'fj') starts with f" => array( 'fj is literally "f" followed by "j"', 'f', true, ); - yield "<⃒ (decodes to 2-byte '<⃒') starts with '<'" => array( + yield "<⃒ (decodes to 2-codepoint '<⃒') starts with '<'" => array( '<⃒script>', '<', true, From 16d591c4a9cdd0e6298ac27c2a9c5338272062e6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 8 Jul 2026 14:30:04 +0200 Subject: [PATCH 09/16] Add extensive description of byte matching prefixes in decoded character refs --- .../html-api/class-wp-html-decoder.php | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-html-decoder.php b/src/wp-includes/html-api/class-wp-html-decoder.php index 187a1ba72e5cc..ddbd95eefde21 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -60,7 +60,47 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen continue; } - // If there is a character reference, then the decoded value must exactly match what follows in the search string. + /** + * A character reference in the haystack decodes into a chunk of one + * or more bytes. The chunk is atomic on the haystack side — decoding + * produces all of it at once and the raw cursor can only skip the + * entire reference — but this is a prefix test, so the search text + * may legitimately end part-way through the chunk. Only the bytes + * where the two overlap can be compared, hence the `min()`. + * + * For example, `fj` (7 bytes) decodes into the chunk + * `fj` (2 bytes). + * + * at + * │ after matching "fj" continue here + * ↓ ↓ + * Haystack: startfjord + * ╰──┬──╯ + * fj - decoded "fjlig;" character reference chunk + * + * after matching "fj" continue here + * ↓ + * Search A: startfjo min( 2, 3 ) = 2: `fj` matches, + * continue scanning at `o`. + * + * Search B: startf min( 2, 1 ) = 1: `f` matches and + * the search text is exhausted, so + * the prefix is confirmed. + * + * Search C: startfr min( 2, 2 ) = 2: `fj` differs + * from `fr`, no match is possible. + * + * Comparing the full chunk length instead of the `min()` would read + * past the end of the search text and wrongly reject Search B: + * + * substr_compare( 'startfjord', 'startf', 5, 2 ); // 1 + * + * After comparing, each cursor must advance by what it consumed: the + * haystack by the raw reference length ($token_length), the search + * text by only the overlapping decoded bytes ($match_length). If the + * search text is exhausted, the match is complete, the loop terminates, + * and the function returns true. + */ $match_length = min( strlen( $next_chunk ), $search_length - $search_at ); if ( 0 !== substr_compare( $search_text, $next_chunk, $search_at, $match_length, $loose_case ) ) { return false; From 21bf19dc99dc2f94191cc6323a32fc6d8bb00d71 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 8 Jul 2026 14:55:19 +0200 Subject: [PATCH 10/16] tighten the comment --- .../html-api/class-wp-html-decoder.php | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-decoder.php b/src/wp-includes/html-api/class-wp-html-decoder.php index ddbd95eefde21..fb85d4ecfc809 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -65,35 +65,43 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen * or more bytes. The chunk is atomic on the haystack side — decoding * produces all of it at once and the raw cursor can only skip the * entire reference — but this is a prefix test, so the search text - * may legitimately end part-way through the chunk. Only the bytes - * where the two overlap can be compared, hence the `min()`. + * may legitimately end part-way through the chunk. The overlapping + * bytes must be compared. * * For example, `fj` (7 bytes) decodes into the chunk * `fj` (2 bytes). * * at - * │ after matching "fj" continue here + * │ after matching "fj" continue here + * │ (+ $token_lengh / 7 bytes) * ↓ ↓ * Haystack: startfjord * ╰──┬──╯ - * fj - decoded "fjlig;" character reference chunk - * - * after matching "fj" continue here - * ↓ - * Search A: startfjo min( 2, 3 ) = 2: `fj` matches, - * continue scanning at `o`. + * fj - decoded "fj" character reference chunk * + * at + * │ after matching "fj" continue here + * │ (+ $match_length / 2 bytes) + * ↓ ↓ + * Search A: startfjord min( 2, 3 ) = 2: `fj` matches, + * │ continue scanning at `o`. + * ↓ * Search B: startf min( 2, 1 ) = 1: `f` matches and * the search text is exhausted, so - * the prefix is confirmed. - * + * │ the prefix is confirmed. + * ↓ * Search C: startfr min( 2, 2 ) = 2: `fj` differs * from `fr`, no match is possible. * - * Comparing the full chunk length instead of the `min()` would read - * past the end of the search text and wrongly reject Search B: + * Using `min()` and `substr_compare()` ensures that only the + * overlapping bytes are compared and length mismatches do not cause + * a false negative in cases A or B: + * + * // Search A: remaining search text is longer than the decoded chunk. + * substr_compare( 'startfjord', 'fj', 5, 5 ); // 1 * - * substr_compare( 'startfjord', 'startf', 5, 2 ); // 1 + * // Search B: remaining search text is shorter than the decoded chunk. + * substr_compare( 'startf', 'fj', 5, 2 ); // -1 * * After comparing, each cursor must advance by what it consumed: the * haystack by the raw reference length ($token_length), the search From 2e18e9f8b6afc0f7400a5d4d2b525898cc909a76 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 8 Jul 2026 17:17:46 +0200 Subject: [PATCH 11/16] Improve clarity --- .../html-api/class-wp-html-decoder.php | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-decoder.php b/src/wp-includes/html-api/class-wp-html-decoder.php index fb85d4ecfc809..081f28ff0509b 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -72,23 +72,27 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen * `fj` (2 bytes). * * at - * │ after matching "fj" continue here - * │ (+ $token_lengh / 7 bytes) + * │ ┌─after matching "fj" continue here + * │ │ (+ $token_length / 7 bytes) * ↓ ↓ * Haystack: startfjord * ╰──┬──╯ * fj - decoded "fj" character reference chunk * - * at - * │ after matching "fj" continue here - * │ (+ $match_length / 2 bytes) + * at + * │ ┌─after matching "fj" continue here + * │ │ (+ $match_length / 2 bytes) * ↓ ↓ - * Search A: startfjord min( 2, 3 ) = 2: `fj` matches, - * │ continue scanning at `o`. + * Search A: startfjord min( 2, 5 ) = 2: `fj` matches, + * continue scanning at `o`. + * + * at * ↓ * Search B: startf min( 2, 1 ) = 1: `f` matches and * the search text is exhausted, so - * │ the prefix is confirmed. + * the prefix is confirmed. + * + * at * ↓ * Search C: startfr min( 2, 2 ) = 2: `fj` differs * from `fr`, no match is possible. @@ -98,14 +102,16 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen * a false negative in cases A or B: * * // Search A: remaining search text is longer than the decoded chunk. - * substr_compare( 'startfjord', 'fj', 5, 5 ); // 1 + * substr_compare( 'startfjord', 'fj', 5, 5 ); // non-zero; incorrect mis-match + * substr_compare( 'startfjord', 'fj', 5, 2 ); // 0; match * * // Search B: remaining search text is shorter than the decoded chunk. - * substr_compare( 'startf', 'fj', 5, 2 ); // -1 + * substr_compare( 'startf', 'fj', 5, 2 ); // non-zero; incorrect mis-match + * substr_compare( 'startf', 'fj', 5, 1 ); // 0; match * * After comparing, each cursor must advance by what it consumed: the - * haystack by the raw reference length ($token_length), the search - * text by only the overlapping decoded bytes ($match_length). If the + * haystack by the raw reference length (`$token_length`), the search + * text by only the overlapping decoded bytes (`$match_length`). If the * search text is exhausted, the match is complete, the loop terminates, * and the function returns true. */ From 56fbd180c7df189fefbe40ef21a9cbf5502c98fb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 8 Jul 2026 17:25:00 +0200 Subject: [PATCH 12/16] Tighten examples --- .../html-api/class-wp-html-decoder.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-decoder.php b/src/wp-includes/html-api/class-wp-html-decoder.php index 081f28ff0509b..f14a294af1cb1 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -97,17 +97,20 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen * Search C: startfr min( 2, 2 ) = 2: `fj` differs * from `fr`, no match is possible. * - * Using `min()` and `substr_compare()` ensures that only the - * overlapping bytes are compared and length mismatches do not cause - * a false negative in cases A or B: + * Using `min()` and `substr_compare()` ensures that only the overlapping bytes + * are compared and length mismatches do not cause a false negative: * * // Search A: remaining search text is longer than the decoded chunk. - * substr_compare( 'startfjord', 'fj', 5, 5 ); // non-zero; incorrect mis-match - * substr_compare( 'startfjord', 'fj', 5, 2 ); // 0; match + * // Using length 5 (`$search_length - $search_at`) would cause a false negative: + * substr_compare( 'startfjord', 'fj', 5, 5 ); + * // Using length 2 (`strlen( $next_chunk )`) matches correctly: + * substr_compare( 'startfjord', 'fj', 5, 2 ); * * // Search B: remaining search text is shorter than the decoded chunk. - * substr_compare( 'startf', 'fj', 5, 2 ); // non-zero; incorrect mis-match - * substr_compare( 'startf', 'fj', 5, 1 ); // 0; match + * // Using length 1 (`$search_length - $search_at`) matches correctly: + * substr_compare( 'startf', 'fj', 5, 1 ); + * // Using length 2 (`strlen( $next_chunk )`) would cause a false negative: + * substr_compare( 'startf', 'fj', 5, 2 ); * * After comparing, each cursor must advance by what it consumed: the * haystack by the raw reference length (`$token_length`), the search From 79d7d83a34bfb5e4c739ec8385c6c90b820b807c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 8 Jul 2026 19:14:15 +0200 Subject: [PATCH 13/16] Tweak and improve explainer --- .../html-api/class-wp-html-decoder.php | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-decoder.php b/src/wp-includes/html-api/class-wp-html-decoder.php index f14a294af1cb1..f074116adcbd7 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -65,58 +65,59 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen * or more bytes. The chunk is atomic on the haystack side — decoding * produces all of it at once and the raw cursor can only skip the * entire reference — but this is a prefix test, so the search text - * may legitimately end part-way through the chunk. The overlapping - * bytes must be compared. + * may legitimately end part-way through the chunk. Only the overlapping + * bytes can be compared. * * For example, `fj` (7 bytes) decodes into the chunk * `fj` (2 bytes). * - * at + * haystack at + * │ * │ ┌─after matching "fj" continue here * │ │ (+ $token_length / 7 bytes) * ↓ ↓ * Haystack: startfjord * ╰──┬──╯ * fj - decoded "fj" character reference chunk + * will be tested against the search text. * - * at + * search at + * │ * │ ┌─after matching "fj" continue here * │ │ (+ $match_length / 2 bytes) * ↓ ↓ * Search A: startfjord min( 2, 5 ) = 2: `fj` matches, * continue scanning at `o`. * - * at + * search at * ↓ * Search B: startf min( 2, 1 ) = 1: `f` matches and * the search text is exhausted, so * the prefix is confirmed. * - * at + * search at * ↓ * Search C: startfr min( 2, 2 ) = 2: `fj` differs * from `fr`, no match is possible. * - * Using `min()` and `substr_compare()` ensures that only the overlapping bytes - * are compared and length mismatches do not cause a false negative: + * The comparison must be limited to the overlap: the smaller of the chunk + * length and the remaining search text length. Relying exclusively on + * either length leads to false negatives: * * // Search A: remaining search text is longer than the decoded chunk. * // Using length 5 (`$search_length - $search_at`) would cause a false negative: - * substr_compare( 'startfjord', 'fj', 5, 5 ); + * substr_compare( 'startfjord', 'fj', 5, 5 ); // non-zero * // Using length 2 (`strlen( $next_chunk )`) matches correctly: - * substr_compare( 'startfjord', 'fj', 5, 2 ); + * substr_compare( 'startfjord', 'fj', 5, 2 ); // 0 * * // Search B: remaining search text is shorter than the decoded chunk. - * // Using length 1 (`$search_length - $search_at`) matches correctly: - * substr_compare( 'startf', 'fj', 5, 1 ); * // Using length 2 (`strlen( $next_chunk )`) would cause a false negative: - * substr_compare( 'startf', 'fj', 5, 2 ); + * substr_compare( 'startf', 'fj', 5, 2 ); // non-zero + * // Using length 1 (`$search_length - $search_at`) matches correctly: + * substr_compare( 'startf', 'fj', 5, 1 ); // 0 * - * After comparing, each cursor must advance by what it consumed: the - * haystack by the raw reference length (`$token_length`), the search - * text by only the overlapping decoded bytes (`$match_length`). If the - * search text is exhausted, the match is complete, the loop terminates, - * and the function returns true. + * A match that exhausts the search text (Search B) ends the loop with + * `$search_at === $search_length`, which the final return reports as success. */ $match_length = min( strlen( $next_chunk ), $search_length - $search_at ); if ( 0 !== substr_compare( $search_text, $next_chunk, $search_at, $match_length, $loose_case ) ) { From b8f24e1a6154268aef7cdf2eb184438b9cf02695 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 8 Jul 2026 19:20:23 +0200 Subject: [PATCH 14/16] Restore separate cursor comment --- src/wp-includes/html-api/class-wp-html-decoder.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-html-decoder.php b/src/wp-includes/html-api/class-wp-html-decoder.php index f074116adcbd7..80d430d1c4c03 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -116,6 +116,10 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen * // Using length 1 (`$search_length - $search_at`) matches correctly: * substr_compare( 'startf', 'fj', 5, 1 ); // 0 * + * After a match, each cursor must advance by its own measure — the raw + * reference and its decoded chunk have unrelated lengths (7 and 2 above): + * `$haystack_at` skips the whole raw reference (`$token_length`) while + * `$search_at` advances only by the decoded bytes matched (`$match_length`). * A match that exhausts the search text (Search B) ends the loop with * `$search_at === $search_length`, which the final return reports as success. */ From 51b4c0853a6d2275bf5135ea203815f0b5a288e0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 8 Jul 2026 23:05:56 +0200 Subject: [PATCH 15/16] Fix combining solidus character ref --- tests/phpunit/tests/html-api/wpHtmlDecoder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlDecoder.php b/tests/phpunit/tests/html-api/wpHtmlDecoder.php index 48504901ab95f..bfef8b244a99a 100644 --- a/tests/phpunit/tests/html-api/wpHtmlDecoder.php +++ b/tests/phpunit/tests/html-api/wpHtmlDecoder.php @@ -403,12 +403,12 @@ public static function data_attribute_starts_with_search_string_boundaries(): Ge true, ); yield "Combining character references (¬̸) full match on '¬̸' prefix" => array( - '¬̸ A negated not?', + '¬̸ A negated not?', '¬̸', true, ); yield "Combining character references (¬̸) partial match on '¬' prefix" => array( - '¬&$#x0338; A negated not?', + '¬̸ A negated not?', '¬', true, ); From 5067289901a32218816fab52dcc81ef186fe6fb0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 8 Jul 2026 23:13:11 +0200 Subject: [PATCH 16/16] Expand boundary test coverage and add @since changelog entry Pin the false paths through the character-reference branch (an attribute ending in a reference no longer matches a longer prefix), encode the explainer comment's Search A/B/C examples as test rows, and exercise the overlap comparison with ASCII-case-insensitive matching. Document the behavior change on attribute_starts_with with an @since 7.1.0 entry. Claude-Session: https://claude.ai/code/session_01K3vwt69YyVNbHAwu1Ue1Nf --- .../html-api/class-wp-html-decoder.php | 3 ++ .../phpunit/tests/html-api/wpHtmlDecoder.php | 51 +++++++++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-decoder.php b/src/wp-includes/html-api/class-wp-html-decoder.php index 80d430d1c4c03..de58591805db9 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -24,6 +24,9 @@ class WP_HTML_Decoder { * false === WP_HTML_Decoder::attribute_starts_with( $value, 'https:', 'ascii-case-insensitive' ); * * @since 6.6.0 + * @since 7.1.0 Matches when the search string ends part-way through a decoded + * character reference, and no longer matches when the attribute + * value is shorter than the search string. * * @param string $haystack String containing the raw non-decoded attribute value. * @param string $search_text Does the attribute value start with this plain string. diff --git a/tests/phpunit/tests/html-api/wpHtmlDecoder.php b/tests/phpunit/tests/html-api/wpHtmlDecoder.php index bfef8b244a99a..46e79e1714de3 100644 --- a/tests/phpunit/tests/html-api/wpHtmlDecoder.php +++ b/tests/phpunit/tests/html-api/wpHtmlDecoder.php @@ -355,21 +355,24 @@ public static function data_case_variants_of_attribute_prefixes() { * * @param string $attribute_value Raw attribute value from HTML string. * @param string $search_string Prefix contained or not contained in encoded attribute value. + * @param string $case_sensitivity Whether to search with ASCII case sensitivity; + * 'ascii-case-insensitive' or 'case-sensitive'. * @param bool $is_match Whether the search string is a prefix for the attribute value. */ public function test_attribute_starts_with_checks_search_string_boundaries( string $attribute_value, string $search_string, + string $case_sensitivity, bool $is_match ): void { if ( $is_match ) { $this->assertTrue( - WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, 'case-sensitive' ), + WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, $case_sensitivity ), 'Should have matched attribute prefix.' ); } else { $this->assertFalse( - WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, 'case-sensitive' ), + WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, $case_sensitivity ), 'Should not have matched attribute with prefix.' ); } @@ -381,37 +384,79 @@ public function test_attribute_starts_with_checks_search_string_boundaries( * @return Generator Test cases. */ public static function data_attribute_starts_with_search_string_boundaries(): Generator { - yield 'Empty attribute does not match non-empty prefix' => array( '', 'http', false ); + yield 'Empty attribute does not match non-empty prefix' => array( '', 'http', 'case-sensitive', false ); yield 'Short attribute does not match longer prefix' => array( 'java', 'javascript', + 'case-sensitive', + false, + ); + yield 'Attribute ending in a character reference does not match a longer prefix' => array( + '&', + '&&', + 'case-sensitive', false, ); yield 'Longer attribute matches shorter prefix' => array( 'javascript', 'java', + 'case-sensitive', true, ); yield "fj (decodes to 2-codepoint 'fj') starts with f" => array( 'fj is literally "f" followed by "j"', 'f', + 'case-sensitive', true, ); yield "<⃒ (decodes to 2-codepoint '<⃒') starts with '<'" => array( '<⃒script>', '<', + 'case-sensitive', true, ); yield "Combining character references (¬̸) full match on '¬̸' prefix" => array( '¬̸ A negated not?', '¬̸', + 'case-sensitive', true, ); yield "Combining character references (¬̸) partial match on '¬' prefix" => array( '¬̸ A negated not?', '¬', + 'case-sensitive', + true, + ); + yield 'Search A: prefix continues past a decoded character reference' => array( + 'startfjord', + 'startfjord', + 'case-sensitive', true, ); + yield 'Search B: prefix ends part-way through a decoded character reference' => array( + 'startfjord', + 'startf', + 'case-sensitive', + true, + ); + yield 'Search C: prefix mismatches within a decoded character reference' => array( + 'startfjord', + 'startfr', + 'case-sensitive', + false, + ); + yield 'ASCII-case-insensitive prefix ends part-way through a decoded character reference' => array( + 'startfjord', + 'STARTF', + 'ascii-case-insensitive', + true, + ); + yield 'ASCII-case-insensitive prefix mismatches within a decoded character reference' => array( + 'startfjord', + 'STARTFR', + 'ascii-case-insensitive', + false, + ); } /**