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

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 pure-ASCII needles fail to match, e.g. PUBLIC does not match public in a DOCTYPE.
  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, etc.
  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.

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.

…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 and quirks-mode class
names; 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.

Replace these with the locale-independent ASCII case helpers.

See #65372.

Claude-Session: https://claude.ai/code/session_01K3vwt69YyVNbHAwu1Ue1Nf
@sirreal
sirreal force-pushed the html-api/review-substr-compare-ascii branch 2 times, most recently from 8f8b882 to 7743311 Compare October 27, 2026 07:28
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 whose C library case-folds non-ASCII bytes, a non-ASCII
byte could fold onto an ASCII letter or onto another non-ASCII byte, so
quirks-mode public identifiers could be misdetected and 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, ASCII
values such as 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
@sirreal
sirreal force-pushed the html-api/review-substr-compare-ascii branch from 7743311 to ba42636 Compare October 27, 2026 08:55
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