HTML API: Review locale-dependent ASCII comparisons#85
Draft
sirreal wants to merge 5 commits into
Draft
Conversation
…ix 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
…ocessor. 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 '</SCRIPT>' 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
DOCTYPE parsing matches the '<!DOCTYPE', 'PUBLIC', and 'SYSTEM' keywords ASCII case-insensitively and folds the DOCTYPE name and identifiers to lowercase for quirks-mode detection. These used substr_compare() with its case-insensitivity flag (locale-dependent on every PHP version) and strtolower() (locale-dependent before PHP 8.2). Under locales which fold across the ASCII boundary (e.g. Turkish with its dotless i), the PUBLIC keyword could fail to match its lowercase spelling and quirks-mode public identifiers could be misdetected; non-ASCII bytes in names and identifiers could be mangled by the lowercase fold. Replace these with the locale-independent ASCII case helpers. This adds a WP_HTML_Decoder dependency to WP_HTML_Doctype_Info, matching the existing dependency in WP_HTML_Tag_Processor. See #65372. Claude-Session: https://claude.ai/code/session_01K3vwt69YyVNbHAwu1Ue1Nf
…rocessor. The HTML Processor folds tag names, breadcrumbs, attribute values, and encoding labels for ASCII case-insensitive comparison using strcasecmp(), strtoupper(), and strtolower(), all of which are locale-dependent before PHP 8.2. WP_HTML_Open_Elements::current_node_is() classified node names with ctype_upper(), which is locale-dependent on every PHP version. Under locales whose C library case-folds non-ASCII bytes, foreign-content end tags and tag queries containing non-ASCII bytes could spuriously match each other, and under locales which fold across the ASCII boundary (e.g. Turkish with its dotless i), ASCII values such as the annotation-xml 'application/xhtml+xml' encoding or the 'hidden' input type could fail to match. Replace these with the locale-independent ASCII case helpers and a locale-independent uppercase check. See #65372. Claude-Session: https://claude.ai/code/session_01K3vwt69YyVNbHAwu1Ue1Nf
…-independent. The 'ascii-case-insensitive' lookup mode relied on locale-sensitive comparisons: substr_compare() with its case-insensitivity flag (locale-dependent on every PHP version) and stripos()/strtoupper() (locale-dependent before PHP 8.2). Under locales whose C library folds non-ASCII bytes, words differing in non-ASCII bytes could spuriously match each other. Additionally, a misplaced parenthesis in read_small_token() uppercased the result of a comparison instead of the compared byte, so ignore-case lookups of small words failed to fold ASCII case beyond the first byte: read_token( 'AB', ..., 'ascii-case-insensitive' ) failed to find 'ab' even though contains( 'AB', 'ascii-case-insensitive' ) reported it present. Replace the comparisons with locale-independent ASCII case folding, keeping WP_Token_Map free of dependencies on other classes. See #65372. Claude-Session: https://claude.ai/code/session_01K3vwt69YyVNbHAwu1Ue1Nf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes ASCII case-insensitive matching locale-independent across the HTML API and
WP_Token_Map.Trac ticket: https://core.trac.wordpress.org/ticket/65372
Problem
Every case-insensitive comparison in the HTML API routed through locale-sensitive PHP functions, in two classes:
substr_compare()with its case-insensitivity flag folds bytes via the C library'stolower()under the current process locale on every PHP version, including current master — the PHP 8.2 locale-independent case RFC did not touch it.strtolower(),strtoupper(),strcasecmp(), andstripos()are locale-dependent before PHP 8.2 (core supports 7.4+), andctype_upper()is locale-dependent on every version.Any plugin can change the process locale via
setlocale(), and some platform defaults already fold non-ASCII bytes. Reproduced on PHP 8.5.8 under macOS' defaultC.UTF-8locale with nosetlocale()call:Consequences:
PUBLICdoes not matchpublicin a DOCTYPE,</SCRIPT>is not escaped when writing JavaScript content into a<script>element, andapplication/xhtml+xmlis not recognized as an HTML integration point encoding.Found via differential fuzzing of
WP_HTML_Decoder::attribute_starts_with()against adecode_attribute()+str_starts_with()oracle; the audit then covered every case-fold site insrc/wp-includes/html-api/plusWP_Token_Map.Changes
One commit per class, tests first:
WP_HTML_Decoder— adds locale-independent primitives (matches_ascii_case_insensitively(),ascii_lowercase(),ascii_uppercase();@since 7.1.0,@access private) and uses them for both comparison paths ofattribute_starts_with(), so the plain-character and character-reference paths can no longer diverge.WP_HTML_Tag_Processor— migrates all 21 sites: sought tag names, quirks-modeclass_list/has_class/add_class/remove_class, attribute-name normalization,get_tag(), RAWTEXT closer scanning, script type detection, and script-content escaping plus its rejection guard.WP_HTML_Doctype_Info—<!DOCTYPE/PUBLIC/SYSTEMkeyword matching and name/identifier folding for quirks-mode detection. Adds aWP_HTML_Decoderdependency, matching the Tag Processor's existing one.WP_HTML_Processor+WP_HTML_Open_Elements— tag queries, breadcrumbs, foreign-content end tags, annotation-xml integration-point encodings, metaContent-Type,hiddeninput type, encoding labels,is_special()/is_void(), andctype_upper()→strspn()incurrent_node_is().WP_Token_Map— the same bug class in its documentedascii-case-insensitivemode (stripos,substr_compare,strtoupper). Kept dependency-free with private copies of the helpers. This also fixes a pre-existing misplaced parenthesis,strtoupper( $a !== $b ), which uppercased a boolean: ignore-case lookups of small words never folded case beyond the first byte, soread_token( 'AB', 0, $l, 'ascii-case-insensitive' )failed to find'ab'even thoughcontains( 'AB', 'ascii-case-insensitive' )reported it present — inconsistent on every PHP version and locale.The fold helpers are
strtr()-based (C-level, locale-free, identical tostrtolower()/strtoupper()output on PHP 8.2+ under the C locale); the comparison helper is a zero-allocation byte loop with the same semantics as thesubstr_compare()calls it replaces, including needle-past-end-of-haystack behavior.Testing notes
C.UTF-8): decoder character-reference rows,next_tag()non-ASCII tag names, quirks-modehas_classandadd_class/remove_classdedup, and bothWP_Token_Maptests all failed before the fix.strcasecmp, andstrtolower/strtoupperfailure modes require pre-8.2 PHP or a glibc Turkish locale, neither reachable on the development machine (macOS'tr_TRcharmap does not implement the dotless-i fold). Those tests are born green but pin the contract with byte pairs chosen to fail loudly if a locale-sensitive comparison ever returns (0xCC/0xEC,0xC4/0xE4= Ì/ì, Ä/ä in ISO-8859-1;0xDD= İ in ISO-8859-9).tolower()folds0xC4onto0xE4, runs the assertions under it, and restores the locale infinally(skips where no such locale exists).</ScRiPt>escaping, mixed-casePublIC, bothContent-Typespellings), so providers were extended rather than duplicated.html-api+html-api-token-map(6037 tests),kses,interactivity-api; PHPCS clean on all changed files.Scope
This covers
src/wp-includes/html-api/andWP_Token_Map(the HTML API's companion class, whose API documents the same'ascii-case-insensitive'contract). Locale-sensitive case folding elsewhere in core (kses, shortcodes, …) is a wider, separate question.Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Fable 5
Used for: The original decoder bug was found during an AI-assisted differential-fuzzing review. The audit, tests, implementation, and commit messages in this PR were authored by Claude Code working from a written handoff and task specification; directed and reviewed by @sirreal.
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.