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..41677111f5941 100644
--- a/src/wp-includes/html-api/class-wp-html-decoder.php
+++ b/src/wp-includes/html-api/class-wp-html-decoder.php
@@ -17,6 +17,9 @@ class WP_HTML_Decoder {
* of how it might be encoded in HTML. For instance, `http:` could be represented as `http:`
* or as `http:` or as `http:` or as `http:`, or in many other ways.
*
+ * This is equivalent to a byte-prefix test against the decoded attribute value, without
+ * the need to allocate and decode the full string.
+ *
* Example:
*
* $value = 'http://wordpress.org/';
@@ -53,24 +56,71 @@ 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 ) ) {
+ /**
+ * The decoded character reference in `$next_chunk` must be compared with the
+ * corresponding `$search_text` bytes checking for matching prefixes. The remaining
+ * search text may be shorter than the decoded chunk, in which case a partial match
+ * satisfies the prefix. Otherwise, if the decoded chunk is fully matched, the
+ * comparison must continue after advancing the appropriate byte lengths: the character
+ * reference token length in the haystack and the decoded chunk length in the
+ * search text.
+ *
+ * For example, consider searches that have reached the character reference
+ * `fj` (7 bytes), decoded into the 2-byte chunk `fj`:
+ *
+ * $haystack_at
+ * │
+ * │ ┌─after matching `fj` continue here
+ * │ │ (advance by $token_length, 7 bytes)
+ * ↓ ↓
+ * Haystack: startfjord
+ * ╰──┬──╯
+ * fj - the decoded chunk, tested against the search text.
+ *
+ * $search_at
+ * │
+ * │ ┌─after matching `fj` continue here
+ * │ │ (advance by $match_length, 2 bytes)
+ * ↓ ↓
+ * Search A: startfjord Compare 2 bytes: `fj` matches,
+ * continue matching at `o`.
+ *
+ * $search_at
+ * ↓
+ * Search B: startf Compare 1 byte: `f` matches and the
+ * search text is exhausted — prefix confirmed.
+ *
+ * $search_at
+ * ↓
+ * Search C: startfr Compare 2 bytes: `fj` differs
+ * from `fr`, no match is possible.
+ *
+ * The `min()` is required in both directions: Search A fails if the
+ * comparison length comes from the search text, Search B if it comes
+ * from the chunk.
+ *
+ * After a match each cursor must advance by the appropriate length, the haystack
+ * cursor by the character reference token length, and the search cursor by the
+ * matched length.
+ */
+ $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;
}
/**
@@ -367,44 +417,38 @@ public static function read_character_reference( $context, $text, $at = 0, &$mat
$after_name = $name_at + $name_length;
- /**
- * For historical reasons, a matched named character reference is left as literal
- * text (its decoded replacement is not used) when all of the following hold:
+ /*
+ * For historical reasons, a matched named character reference is left as
+ * literal text — its decoded replacement is not used — when all of the
+ * following hold:
*
* 1. It was matched in attribute context.
- * 2. The match does not end in U+003B SEMICOLON (;) — i.e. it is one of the
+ * 2. The match does not end in U+003B SEMICOLON (;), i.e. it is one of the
* legacy forms recognized without a trailing semicolon.
* 3. The next input character is U+003D EQUALS SIGN (=) or an ASCII alphanumeric.
*
- * Some illustrative examples follow. Note that both `not` and `not;` appear in the
- * named character references list. References start with `&` and typically end with
- * `;`, but the legacy forms are recognized without one.
+ * For example — note that both `not` and `not;` appear in the named
+ * character reference table:
*
- * - In _data context_, "¬me" is decoded to "¬me": condition 1 fails (not an
- * attribute), so the reference is decoded.
- * - In _attribute context_, "¬me" is decoded to "¬me": the longest match is
- * "not;", which ends in a semicolon, so condition 2 fails.
- * - In _attribute context_, "¬己" is decoded to "¬己": the following character
- * "己" is a letter but not an ASCII alphanumeric (nor "="), so condition 3 fails.
- * - In _attribute context_, "¬" is decoded to "¬": there is no next input
- * character, so condition 3 fails.
- * - In _attribute context_, "¬=me" is left as the literal text "¬=me": all
- * three conditions hold.
- * - In _attribute context_, "¬me" is left as the literal text "¬me": all
- * three conditions hold.
+ * - In data context, `¬me` decodes to `¬me`: condition 1 fails.
+ * - In attribute context:
+ * - `¬me` decodes to `¬me`: the longest match, `not;`, ends in a
+ * semicolon, so condition 2 fails.
+ * - `¬己` decodes to `¬己`: `己` is a letter, but neither `=` nor an
+ * ASCII alphanumeric, so condition 3 fails.
+ * - `¬` decodes to `¬`: there is no next input character, so
+ * condition 3 fails.
+ * - `¬=me` and `¬me` are left as literal text: all three hold.
*
- * Without these special rules, ordinary URL query strings could have surprising
+ * Without these rules, ordinary URL query strings could have surprising
* replacements applied. Consider:
*
*
*
- * The literal attribute value `/?random°ree>=0<=360¬=90` is preserved
- * by the special handling. Otherwise, the value would decode to
- * `/?random°ree>=0<=360¬=90`, which is unlikely to be the author's intent.
- *
- * (Authors should not rely on this. Escaping the example as
- * `/?random°ree>=0<=360¬=90` produces the intended
- * value regardless of the following character.)
+ * The special handling preserves the literal attribute value; plain decoding
+ * would produce `/?random°ree>=0<=360¬=90`, which is unlikely to be the
+ * author's intent. (Authors should not rely on this: escaping each `&` as
+ * `&` produces the intended value regardless of the following character.)
*
* @see https://html.spec.whatwg.org/multipage/parsing.html#named-character-reference-state
* @see https://html.spec.whatwg.org/multipage/named-characters.html#named-character-references
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.
*