From bf02e3215b940100caa0eb019e9ccb9c64f2d62a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 8 Jul 2026 23:39:02 +0200 Subject: [PATCH 1/5] HTML API: Add ASCII case helpers, fix locale-dependent attribute prefix matching. WP_HTML_Decoder::attribute_starts_with() promises ASCII-only case folding in its 'ascii-case-insensitive' mode but relied on two locale-sensitive comparisons: per-byte strtolower() (locale-dependent before PHP 8.2) for plain characters, and substr_compare() with its case-insensitivity flag (locale-dependent on every PHP version) for decoded character references. Under locales whose C library folds non-ASCII bytes (e.g. C.UTF-8 on macOS, de_DE.ISO8859-1), non-ASCII bytes could spuriously match as case variants of each other, and the same decoded value could produce different answers depending on whether it appeared raw or encoded in the attribute value. Introduce locale-independent ASCII case helpers on WP_HTML_Decoder and use them for both comparison paths so they can no longer diverge. Found via differential fuzzing against a decode_attribute() + str_starts_with() oracle. See #65372. Claude-Session: https://claude.ai/code/session_01K3vwt69YyVNbHAwu1Ue1Nf --- .../html-api/class-wp-html-decoder.php | 117 +++++++++++++++++- .../phpunit/tests/html-api/wpHtmlDecoder.php | 67 ++++++++++ 2 files changed, 182 insertions(+), 2 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 6c375b8512946..f3b8b0a38354a 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -10,6 +10,116 @@ * @since 6.6.0 */ class WP_HTML_Decoder { + /** + * Indicates if a span of text matches a given string, ignoring ASCII case. + * + * Matching is performed byte-by-byte and folds only the ASCII uppercase letters + * A-Z onto their lowercase counterparts, regardless of the process locale. + * + * This exists because PHP's built-in case-insensitive comparisons are locale + * sensitive: `substr_compare()` with its case-insensitivity flag and (before + * PHP 8.2) `strcasecmp()`, `strtolower()`, and friends fold bytes according + * to the current process locale, which may treat non-ASCII bytes as case + * variants of each other or of ASCII letters. HTML requires ASCII + * case insensitivity regardless of locale. + * + * The span begins at the given byte offset into the text and runs for the + * byte length of the search string. A span extending past the end of the + * text never matches, even if the available bytes match the search string. + * + * Example: + * + * true === WP_HTML_Decoder::matches_ascii_case_insensitively( 'DIV', 'div' ); + * true === WP_HTML_Decoder::matches_ascii_case_insensitively( '', 'HTML', 10 ); + * false === WP_HTML_Decoder::matches_ascii_case_insensitively( "\xCC", "\xEC" ); + * false === WP_HTML_Decoder::matches_ascii_case_insensitively( 'DIVERT', 'div', 3 ); + * + * @since 7.1.0 + * + * @access private + * + * @see https://infra.spec.whatwg.org/#ascii-case-insensitive + * + * @param string $text Text possibly containing the search string. + * @param string $search Search string; its byte length determines the span length. + * @param int $offset Optional. Byte offset into the text where the span begins. Default 0. + * @return bool Whether the span is an ASCII case-insensitive match for the search string. + */ + public static function matches_ascii_case_insensitively( string $text, string $search, int $offset = 0 ): bool { + $length = strlen( $search ); + if ( $offset < 0 || $offset + $length > strlen( $text ) ) { + return false; + } + + for ( $at = 0; $at < $length; $at++ ) { + $text_byte = $text[ $offset + $at ]; + $search_byte = $search[ $at ]; + + if ( $text_byte === $search_byte ) { + continue; + } + + $text_ord = ord( $text_byte ); + $search_ord = ord( $search_byte ); + + // Fold uppercase ASCII letters onto their lowercase counterparts; no other bytes fold. + if ( $text_ord >= 0x41 && $text_ord <= 0x5A ) { + $text_ord += 0x20; + } + if ( $search_ord >= 0x41 && $search_ord <= 0x5A ) { + $search_ord += 0x20; + } + + if ( $text_ord !== $search_ord ) { + return false; + } + } + + return true; + } + + /** + * Returns the ASCII lowercase version of a given string. + * + * Only the ASCII uppercase letters A-Z are folded onto their lowercase + * counterparts; all other bytes are left unchanged. This differs from + * `strtolower()`, which before PHP 8.2 folds bytes according to the + * current process locale. + * + * @since 7.1.0 + * + * @access private + * + * @see https://infra.spec.whatwg.org/#ascii-lowercase + * + * @param string $text Text to fold. + * @return string ASCII-lowercase version of the given text. + */ + public static function ascii_lowercase( string $text ): string { + return strtr( $text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz' ); + } + + /** + * Returns the ASCII uppercase version of a given string. + * + * Only the ASCII lowercase letters a-z are folded onto their uppercase + * counterparts; all other bytes are left unchanged. This differs from + * `strtoupper()`, which before PHP 8.2 folds bytes according to the + * current process locale. + * + * @since 7.1.0 + * + * @access private + * + * @see https://infra.spec.whatwg.org/#ascii-uppercase + * + * @param string $text Text to fold. + * @return string ASCII-uppercase version of the given text. + */ + public static function ascii_uppercase( string $text ): string { + return strtr( $text, 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' ); + } + /** * Indicates if an attribute value starts with a given raw string value. * @@ -40,7 +150,7 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen while ( $search_at < $search_length && $haystack_at < $haystack_end ) { $chars_match = $loose_case - ? strtolower( $haystack[ $haystack_at ] ) === strtolower( $search_text[ $search_at ] ) + ? self::matches_ascii_case_insensitively( $haystack, $search_text[ $search_at ], $haystack_at ) : $haystack[ $haystack_at ] === $search_text[ $search_at ]; $is_introducer = '&' === $haystack[ $haystack_at ]; @@ -61,7 +171,10 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen } // 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 ) ) { + $chunk_matches = $loose_case + ? self::matches_ascii_case_insensitively( $search_text, $next_chunk, $search_at ) + : 0 === substr_compare( $search_text, $next_chunk, $search_at, strlen( $next_chunk ) ); + if ( ! $chunk_matches ) { return false; } diff --git a/tests/phpunit/tests/html-api/wpHtmlDecoder.php b/tests/phpunit/tests/html-api/wpHtmlDecoder.php index 7fe39a63d1f3b..6f88292a5a3e0 100644 --- a/tests/phpunit/tests/html-api/wpHtmlDecoder.php +++ b/tests/phpunit/tests/html-api/wpHtmlDecoder.php @@ -389,6 +389,73 @@ 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 ), + + /* + * ASCII case insensitivity must not extend to non-ASCII bytes, no matter + * the process locale. These byte pairs are case variants of each other in + * common single-byte charmaps (0xCC/0xEC are Ì/ì in Latin-1) and serve as + * canaries: they will match if a locale-sensitive comparison sneaks in. + * The same decoded value must produce the same answer whether it appears + * raw or as a character reference ("\xCC\xB8" is U+0338 in UTF-8). + */ + 'Raw non-ASCII byte does not case-fold' => array( "\xCC\xB8", "\xEC\xB8", 'ascii-case-insensitive', false ), + 'Encoded non-ASCII byte does not case-fold' => array( '̸', "\xEC\xB8", 'ascii-case-insensitive', false ), + 'Encoded non-ASCII matches its exact bytes' => array( '̸', "\xCC\xB8", 'ascii-case-insensitive', true ), + 'Encoded non-ASCII exact bytes are sensible' => array( '̸', "\xCC\xB8", 'case-sensitive', true ), + 'Raw Ä does not loosely match raw ä' => array( "\xC4", "\xE4", 'ascii-case-insensitive', false ), + 'Encoded Ä matches its exact UTF-8 bytes' => array( 'Ä', "\xC3\x84", 'ascii-case-insensitive', true ), + 'Encoded Ä does not loosely match ä bytes' => array( 'Ä', "\xC3\xA4", 'ascii-case-insensitive', false ), + 'ASCII case folds around encoded spans' => array( 'JAVASCRIPT:', 'javascript:', 'ascii-case-insensitive', true ), + 'Search text ending inside an encoded span' => array( '…', "\xE2\x80", 'ascii-case-insensitive', false ), ); } + + /** + * Ensures ASCII case-insensitive matching ignores the process locale. + * + * Locale-sensitive case comparisons can treat non-ASCII bytes as case + * variants of each other, e.g. 0xCC/0xEC (Ì/ì in Latin-1). This test + * switches to a locale where the C library folds those bytes and + * asserts that matching remains byte-exact outside of ASCII. + * + * @ticket 65372 + */ + public function test_attribute_starts_with_ignores_process_locale() { + $folding_locale = null; + foreach ( array( 'de_DE.ISO8859-1', 'de_DE.iso88591', 'de_DE', 'C.UTF-8', 'C.utf8', 'en_US.UTF-8' ) as $locale ) { + // Detect a locale whose C-library case folding maps Ä (0xC4) onto ä (0xE4). + if ( false !== setlocale( LC_CTYPE, $locale ) && 0 === substr_compare( "\xC4", "\xE4", 0, 1, true ) ) { + $folding_locale = $locale; + break; + } + } + + if ( self::$original_lc_ctype ) { + setlocale( LC_CTYPE, self::$original_lc_ctype ); + } + + if ( null === $folding_locale ) { + $this->markTestSkipped( 'No locale with non-ASCII case folding is available.' ); + } + + setlocale( LC_CTYPE, $folding_locale ); + try { + $this->assertFalse( + WP_HTML_Decoder::attribute_starts_with( "\xC4hnlich", "\xE4hnlich", 'ascii-case-insensitive' ), + 'Should not have case-folded a raw non-ASCII byte.' + ); + $this->assertFalse( + WP_HTML_Decoder::attribute_starts_with( '̸', "\xEC\xB8", 'ascii-case-insensitive' ), + 'Should not have case-folded a decoded non-ASCII byte.' + ); + $this->assertTrue( + WP_HTML_Decoder::attribute_starts_with( 'JAVASCRIPT:alert(1)', 'javascript:', 'ascii-case-insensitive' ), + 'Should have case-folded ASCII letters.' + ); + } finally { + if ( self::$original_lc_ctype ) { + setlocale( LC_CTYPE, self::$original_lc_ctype ); + } + } + } } From 7788b87192f92fc3b56f6310c137b983ec46fc09 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 8 Jul 2026 23:45:09 +0200 Subject: [PATCH 2/5] HTML API: Use locale-independent ASCII case comparisons in the Tag Processor. Tag, attribute, and class name matching in WP_HTML_Tag_Processor promises ASCII case insensitivity but relied on locale-sensitive comparisons: substr_compare() with its case-insensitivity flag (locale-dependent on every PHP version) when matching sought tag names, quirks-mode class names, and SCRIPT tag contents; and strtolower()/strtoupper()/stripos() (locale-dependent before PHP 8.2) when normalizing names for comparison. Under locales whose C library case-folds non-ASCII bytes, tag and class names containing non-ASCII bytes could spuriously match each other. Under locales which fold across the ASCII boundary (e.g. Turkish with its dotless i), SCRIPT content escaping could miss '' spellings and RAWTEXT closers could be misrecognized. Replace these with the locale-independent ASCII case helpers. See #65372. Claude-Session: https://claude.ai/code/session_01K3vwt69YyVNbHAwu1Ue1Nf --- .../html-api/class-wp-html-tag-processor.php | 50 +++++++-------- .../tests/html-api/wpHtmlProcessor.php | 64 +++++++++++++++++++ .../tests/html-api/wpHtmlTagProcessor.php | 48 ++++++++++++++ .../wpHtmlTagProcessorModifiableText.php | 9 +++ 4 files changed, 146 insertions(+), 25 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index a3dac33fc55ca..f51e9d2d70a3a 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -1219,7 +1219,7 @@ public function class_list() { $name = substr( $class, $at, $length ); if ( $is_quirks ) { - $name = strtolower( $name ); + $name = WP_HTML_Decoder::ascii_lowercase( $name ); } $at += $length; @@ -1257,7 +1257,9 @@ public function has_class( $wanted_class ): ?bool { foreach ( $this->class_list() as $class_name ) { if ( strlen( $class_name ) === $wanted_length && - 0 === substr_compare( $class_name, $wanted_class, 0, strlen( $wanted_class ), $case_insensitive ) + ( $case_insensitive + ? WP_HTML_Decoder::matches_ascii_case_insensitively( $class_name, $wanted_class ) + : $class_name === $wanted_class ) ) { return true; } @@ -1448,10 +1450,7 @@ private function skip_rcdata( string $tag_name ): bool { * normalization could not be part of a tag name. */ for ( $i = 0; $i < $tag_length; $i++ ) { - $tag_char = $tag_name[ $i ]; - $html_char = $html[ $at + $i ]; - - if ( $html_char !== $tag_char && strtoupper( $html_char ) !== $tag_char ) { + if ( ! WP_HTML_Decoder::matches_ascii_case_insensitively( $html, $tag_name[ $i ], $at + $i ) ) { $at += $i; continue 2; } @@ -2267,7 +2266,7 @@ private function parse_next_attribute(): bool { * * @see https://html.spec.whatwg.org/#attribute-name-state */ - $comparable_name = strtolower( str_replace( "\x00", "\u{FFFD}", $attribute_name ) ); + $comparable_name = WP_HTML_Decoder::ascii_lowercase( str_replace( "\x00", "\u{FFFD}", $attribute_name ) ); // If an attribute is listed many times, only use the first declaration and ignore the rest. if ( ! isset( $this->attributes[ $comparable_name ] ) ) { @@ -2446,7 +2445,7 @@ private function class_name_updates_to_attributes_updates(): void { if ( $is_quirks ) { foreach ( $this->classname_updates as $updated_name => $action ) { if ( self::REMOVE_CLASS === $action ) { - $to_remove[] = strtolower( $updated_name ); + $to_remove[] = WP_HTML_Decoder::ascii_lowercase( $updated_name ); } } } else { @@ -2473,7 +2472,7 @@ private function class_name_updates_to_attributes_updates(): void { } $name = substr( $existing_class, $at, $name_length ); - $comparable_class_name = $is_quirks ? strtolower( $name ) : $name; + $comparable_class_name = $is_quirks ? WP_HTML_Decoder::ascii_lowercase( $name ) : $name; $at += $name_length; // If this class is marked for removal, remove it and move on to the next one. @@ -2509,7 +2508,7 @@ private function class_name_updates_to_attributes_updates(): void { // Add new classes by appending those which haven't already been seen. foreach ( $this->classname_updates as $name => $operation ) { - $comparable_name = $is_quirks ? strtolower( $name ) : $name; + $comparable_name = $is_quirks ? WP_HTML_Decoder::ascii_lowercase( $name ) : $name; if ( self::ADD_CLASS === $operation && ! in_array( $comparable_name, $seen, true ) ) { $modified = true; @@ -2809,7 +2808,7 @@ public function get_attribute( $name ) { return null; } - $comparable = strtolower( $name ); + $comparable = WP_HTML_Decoder::ascii_lowercase( $name ); /* * For every attribute other than `class` it's possible to perform a quick check if @@ -2912,7 +2911,7 @@ public function get_attribute_names_with_prefix( $prefix ): ?array { return null; } - $comparable = strtolower( $prefix ); + $comparable = WP_HTML_Decoder::ascii_lowercase( $prefix ); $matches = array(); foreach ( array_keys( $this->attributes ) as $attr_name ) { @@ -2958,7 +2957,7 @@ public function get_tag(): ?string { $tag_name = str_replace( "\x00", "\u{FFFD}", substr( $this->html, $this->tag_name_starts_at, $this->tag_name_length ) ); if ( self::STATE_MATCHED_TAG === $this->parser_state ) { - return strtoupper( $tag_name ); + return WP_HTML_Decoder::ascii_uppercase( $tag_name ); } if ( @@ -2989,7 +2988,7 @@ public function get_qualified_tag_name(): ?string { return $tag_name; } - $lower_tag_name = strtolower( $tag_name ); + $lower_tag_name = WP_HTML_Decoder::ascii_lowercase( $tag_name ); if ( 'math' === $this->get_namespace() ) { return $lower_tag_name; } @@ -3138,7 +3137,7 @@ public function get_qualified_attribute_name( $attribute_name ): ?string { } $namespace = $this->get_namespace(); - $lower_name = strtolower( $attribute_name ); + $lower_name = WP_HTML_Decoder::ascii_lowercase( $attribute_name ); if ( 'math' === $namespace && 'definitionurl' === $lower_name ) { return 'definitionURL'; @@ -3911,9 +3910,10 @@ public function set_modifiable_text( string $plaintext_content ): bool { * HTML structure is rejected here. It’s the responsibility of calling code to * perform whatever semantic escaping is necessary to avoid problematic strings. */ + $comparable_content = WP_HTML_Decoder::ascii_lowercase( $plaintext_content ); if ( - false !== stripos( $plaintext_content, ' If the script block's type string is a JavaScript MIME type essence match, then @@ -4300,7 +4300,7 @@ private static function escape_javascript_script_contents( string $sourcecode ): $has_closing_slash = $tag_name_at < $end && '/' === $sourcecode[ $tag_name_at ]; $tag_name_at += $has_closing_slash ? 1 : 0; - if ( 0 !== substr_compare( $sourcecode, 'script', $tag_name_at, 6, true ) ) { + if ( ! WP_HTML_Decoder::matches_ascii_case_insensitively( $sourcecode, 'script', $tag_name_at ) ) { $at = $tag_at + 1; continue; } @@ -4415,7 +4415,7 @@ public function set_attribute( $name, $value ): bool { if ( true === $value ) { $updated_attribute = $name; } else { - $comparable_name = strtolower( $name ); + $comparable_name = WP_HTML_Decoder::ascii_lowercase( $name ); /** * Escape attribute values appropriately. @@ -4451,7 +4451,7 @@ public function set_attribute( $name, $value ): bool { * * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive */ - $comparable_name = strtolower( $name ); + $comparable_name = WP_HTML_Decoder::ascii_lowercase( $name ); if ( isset( $this->attributes[ $comparable_name ] ) ) { /* @@ -4527,7 +4527,7 @@ public function remove_attribute( $name ): bool { * * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive */ - $name = strtolower( $name ); + $name = WP_HTML_Decoder::ascii_lowercase( $name ); /* * Any calls to update the `class` attribute directly should wipe out any @@ -4612,7 +4612,7 @@ public function add_class( $class_name ): bool { foreach ( $this->classname_updates as $updated_name => $action ) { if ( strlen( $updated_name ) === $class_name_length && - 0 === substr_compare( $updated_name, $class_name, 0, $class_name_length, true ) + WP_HTML_Decoder::matches_ascii_case_insensitively( $updated_name, $class_name ) ) { $this->classname_updates[ $updated_name ] = self::ADD_CLASS; return true; @@ -4654,7 +4654,7 @@ public function remove_class( $class_name ): bool { foreach ( $this->classname_updates as $updated_name => $action ) { if ( strlen( $updated_name ) === $class_name_length && - 0 === substr_compare( $updated_name, $class_name, 0, $class_name_length, true ) + WP_HTML_Decoder::matches_ascii_case_insensitively( $updated_name, $class_name ) ) { $this->classname_updates[ $updated_name ] = self::REMOVE_CLASS; return true; @@ -4821,7 +4821,7 @@ private function matches(): bool { $tag_name = $this->get_tag(); if ( strlen( $this->sought_tag_name ) !== strlen( $tag_name ) || - 0 !== substr_compare( $tag_name, $this->sought_tag_name, 0, null, true ) + ! WP_HTML_Decoder::matches_ascii_case_insensitively( $tag_name, $this->sought_tag_name ) ) { return false; } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index a978422c20098..dade2f3d27ca5 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -836,6 +836,70 @@ public function test_class_list_quirks_mode() { ); } + /** + * Ensures quirks-mode class matching folds ASCII letters only, regardless of locale. + * + * The byte pair 0xCC/0xEC (Ì/ì in ISO-8859-1) is a case pair in common single-byte + * charmaps: a locale-sensitive comparison would treat the class names below as + * ASCII case-insensitive matches for each other. + * + * @ticket 65372 + * + * @covers ::has_class + */ + public function test_has_class_quirks_mode_folds_ascii_case_only() { + $processor = WP_HTML_Processor::create_full_parser( "" ); + $processor->next_tag( 'SPAN' ); + $this->assertTrue( + $processor->has_class( "gr\xCCn" ), + 'Should have matched the class name with ASCII letters case-folded.' + ); + $this->assertFalse( + $processor->has_class( "gr\xECn" ), + 'Should not have case-folded non-ASCII bytes in class names.' + ); + } + + /** + * Ensures quirks-mode class updates fold ASCII letters only, regardless of locale. + * + * @ticket 65372 + * + * @covers ::add_class + * @covers ::remove_class + */ + public function test_add_class_quirks_mode_folds_ascii_case_only() { + $processor = WP_HTML_Processor::create_full_parser( '' ); + $processor->next_tag( 'SPAN' ); + $processor->add_class( "GR\xCCN" ); + $processor->add_class( "gr\xCCn" ); + $this->assertSame( + "", + $processor->get_updated_html(), + 'Should have deduplicated ASCII case variants of the same class name.' + ); + + $processor = WP_HTML_Processor::create_full_parser( '' ); + $processor->next_tag( 'SPAN' ); + $processor->add_class( "GR\xCCN" ); + $processor->add_class( "gr\xECn" ); + $this->assertSame( + "", + $processor->get_updated_html(), + 'Should have added both class names: non-ASCII bytes are not case variants.' + ); + + $processor = WP_HTML_Processor::create_full_parser( '' ); + $processor->next_tag( 'SPAN' ); + $processor->add_class( "GR\xCCN" ); + $processor->remove_class( "gr\xECn" ); + $this->assertSame( + "", + $processor->get_updated_html(), + 'Should not have cancelled a pending class addition differing in non-ASCII bytes.' + ); + } + /** * Ensures that the processor correctly adjusts the namespace * for elements inside HTML integration points. diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 88762ddbb60c4..d732900ce99f6 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -3247,6 +3247,54 @@ public function test_recognizes_uppercase_tag_name( string $char ) { ); } + /** + * Ensures tag-name matching folds ASCII letters only, regardless of locale. + * + * Tag names may contain non-ASCII bytes, which must match byte-for-byte. + * The byte pair 0xCC/0xEC (Ì/ì in ISO-8859-1) is a case pair in common + * single-byte charmaps: a locale-sensitive comparison would treat the tag + * names below as ASCII case-insensitive matches for each other. + * + * @ticket 65372 + * + * @covers ::next_tag + */ + public function test_next_tag_matches_tag_names_with_ascii_case_folding_only() { + $processor = new WP_HTML_Tag_Processor( "" ); + $this->assertFalse( + $processor->next_tag( array( 'tag_name' => "d\xECta" ) ), + 'Should not have matched a tag name differing in non-ASCII bytes.' + ); + + $processor = new WP_HTML_Tag_Processor( "" ); + $this->assertTrue( + $processor->next_tag( array( 'tag_name' => "D\xCCTA" ) ), + 'Should have matched the tag name with ASCII letters case-folded.' + ); + } + + /** + * Ensures RAWTEXT closer scanning folds ASCII letters only, regardless of locale. + * + * The byte 0xFD is ı (LATIN SMALL LETTER DOTLESS I) in ISO-8859-9, whose + * uppercase form is the ASCII letter I: under a Turkish locale a + * locale-sensitive comparison would recognize `` as a TITLE + * tag closer. + * + * @ticket 65372 + * + * @covers ::next_tag + */ + public function test_rawtext_closer_matching_folds_ascii_case_only() { + $processor = new WP_HTML_Tag_Processor( "a</t\xFDtle>b" ); + $this->assertTrue( $processor->next_tag(), 'Should have found the TITLE tag.' ); + $this->assertSame( + "ab", + $processor->get_modifiable_text(), + 'Should not have recognized a tag closer containing a non-ASCII byte in its tag name.' + ); + } + /** * Data provider. * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php index 4a09403b7b23e..b85fc8ad2f9b7 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php @@ -494,6 +494,8 @@ public static function data_unallowed_modifiable_text_updates() { 'Non-JS SCRIPT with ' => array( '', 'Just a ' ), 'Non-JS SCRIPT with ', '