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 6c375b8512946..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. @@ -53,24 +56,87 @@ 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; 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 ) ) { + /** + * 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 overlapping + * bytes can be compared. + * + * For example, `fj` (7 bytes) decodes into the chunk + * `fj` (2 bytes). + * + * 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. + * + * search at + * │ + * │ ┌─after matching "fj" continue here + * │ │ (+ $match_length / 2 bytes) + * ↓ ↓ + * Search A: startfjord min( 2, 5 ) = 2: `fj` matches, + * continue scanning at `o`. + * + * search at + * ↓ + * Search B: startf min( 2, 1 ) = 1: `f` matches and + * the search text is exhausted, so + * the prefix is confirmed. + * + * search at + * ↓ + * Search C: startfr min( 2, 2 ) = 2: `fj` differs + * from `fr`, no match is possible. + * + * 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 ); // non-zero + * // Using length 2 (`strlen( $next_chunk )`) matches correctly: + * substr_compare( 'startfjord', 'fj', 5, 2 ); // 0 + * + * // Search B: remaining search text is shorter than the decoded chunk. + * // Using length 2 (`strlen( $next_chunk )`) would cause a false negative: + * 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 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. + */ + $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 7fe39a63d1f3b..46e79e1714de3 100644 --- a/tests/phpunit/tests/html-api/wpHtmlDecoder.php +++ b/tests/phpunit/tests/html-api/wpHtmlDecoder.php @@ -346,6 +346,119 @@ 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 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_sensitivity ), + 'Should have matched attribute prefix.' + ); + } else { + $this->assertFalse( + WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, $case_sensitivity ), + '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', '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, + ); + } + /** * Ensures that `attribute_starts_with` respects the case sensitivity argument. *