Skip to content

HTML API: Review locale-dependent ASCII comparisons#85

Draft
sirreal wants to merge 5 commits into
trunkfrom
html-api/review-substr-compare-ascii
Draft

HTML API: Review locale-dependent ASCII comparisons#85
sirreal wants to merge 5 commits into
trunkfrom
html-api/review-substr-compare-ascii

Conversation

@sirreal

@sirreal sirreal commented Jul 9, 2026

Copy link
Copy Markdown
Owner

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's tolower() 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(), and stripos() are locale-dependent before PHP 8.2 (core supports 7.4+), and ctype_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' default C.UTF-8 locale with no setlocale() call:

substr_compare( "\xEC\xB8", "\xCC\xB8", 0, 2, true ); // 0 — "matches"

// U+0338 is "\xCC\xB8" in UTF-8. Same decoded value, opposite answers:
WP_HTML_Decoder::attribute_starts_with( '̸',  "\xEC\xB8", 'ascii-case-insensitive' ); // true  (wrong)
WP_HTML_Decoder::attribute_starts_with( "\xCC\xB8", "\xEC\xB8", 'ascii-case-insensitive' ); // false (correct)

Consequences:

  1. Non-ASCII bytes spuriously match as case variants of each other: tag names, quirks-mode class names, attribute prefixes, DOCTYPE identifiers, and token-map words differing only in non-ASCII bytes are treated as the same name.
  2. Under locales which fold across the ASCII boundary (e.g. Turkish dotless-i on glibc), pure-ASCII needles fail to match: PUBLIC does not match public in a DOCTYPE, </SCRIPT> is not escaped when writing JavaScript content into a <script> element, and application/xhtml+xml is not recognized as an HTML integration point encoding.
  3. The same value gives different answers raw vs. encoded, and behavior varies across PHP versions and process locales.

Found via differential fuzzing of WP_HTML_Decoder::attribute_starts_with() against a decode_attribute() + str_starts_with() oracle; the audit then covered every case-fold site in src/wp-includes/html-api/ plus WP_Token_Map.

Changes

One commit per class, tests first:

  1. 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 of attribute_starts_with(), so the plain-character and character-reference paths can no longer diverge.
  2. WP_HTML_Tag_Processor — migrates all 21 sites: sought tag names, quirks-mode class_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.
  3. WP_HTML_Doctype_Info<!DOCTYPE/PUBLIC/SYSTEM keyword matching and name/identifier folding for quirks-mode detection. Adds a WP_HTML_Decoder dependency, matching the Tag Processor's existing one.
  4. WP_HTML_Processor + WP_HTML_Open_Elements — tag queries, breadcrumbs, foreign-content end tags, annotation-xml integration-point encodings, meta Content-Type, hidden input type, encoding labels, is_special()/is_void(), and ctype_upper()strspn() in current_node_is().
  5. WP_Token_Map — the same bug class in its documented ascii-case-insensitive mode (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, so read_token( 'AB', 0, $l, 'ascii-case-insensitive' ) failed to find 'ab' even though contains( '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 to strtolower()/strtoupper() output on PHP 8.2+ under the C locale); the comparison helper is a zero-allocation byte loop with the same semantics as the substr_compare() calls it replaces, including needle-past-end-of-haystack behavior.

Testing notes

  • Red-first locally (macOS, PHP 8.5.8, default C.UTF-8): decoder character-reference rows, next_tag() non-ASCII tag names, quirks-mode has_class and add_class/remove_class dedup, and both WP_Token_Map tests all failed before the fix.
  • Canary pins: the DOCTYPE, script-escaping, strcasecmp, and strtolower/strtoupper failure modes require pre-8.2 PHP or a glibc Turkish locale, neither reachable on the development machine (macOS' tr_TR charmap 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).
  • A dedicated decoder test probes for a locale whose tolower() folds 0xC4 onto 0xE4, runs the assertions under it, and restores the locale in finally (skips where no such locale exists).
  • Existing tests already covered many fold-direction behaviors (</ScRiPt> escaping, mixed-case PublIC, both Content-Type spellings), so providers were extended rather than duplicated.
  • Suites run clean: 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/ and WP_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.

sirreal added 5 commits July 8, 2026 23:39
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant