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&degree&gt=0&lt=360&not=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. *