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.
*