Fix browse mode failing to read pages with malformed ARIA attributes - #20629
Fix browse mode failing to read pages with malformed ARIA attributes#20629akj wants to merge 2 commits into
Conversation
Malformed markup such as aria-label"foo" produces IA2 attribute names containing quotes. The virtual buffer emitted these verbatim as XML attribute names, which expat rejects, causing an error sound and unreadable content in browse mode. sanitizeXMLAttribName now replaces every character outside a conservative ASCII allowlist, and duplicate attribute names produced by sanitization are dropped, since duplicate attributes are also invalid XML. Fixes nvaccess#7173.
|
@akj have you investigated using an existing XML sanitization library to do this?
|
Review rework: sanitize once in addAttribute (the attribute map's single insertion point) instead of on every serialization, so the map only ever holds valid XML names and the per-fetch dedup guard is unnecessary. A sanitized name never overwrites an attribute whose name was genuinely valid. Names are also guarded against invalid leading characters and emptiness (NameStartChar is stricter than NameChar). Adds unit tests pinning the contract that every name the sanitizer can produce is accepted by the expat configuration NVDA parses buffer markup with, including the XML 1.0 fourth edition canary U+0132.
|
Yes, I did look into this before settling on the current approach — and I share the concern about bespoke logic; it's a big part of why the fix ended up shaped the way it is.
On the maintainer burden specifically: rather than asking future maintainers to trust a character list in a C++ header, I've added unit tests ( While revisiting this I also tightened the fix based on further review: names are now sanitized once when an attribute is added to the buffer rather than on every serialization, and a couple of edge cases (invalid leading characters, name collisions after sanitization) are handled. |
Link to issue number:
Fixes #7173
Summary of the issue:
When a page contains malformed markup such as
<section aria-label"almenük" role="region">(note the missing=), the browser exposes an IA2 object attribute whose name contains quote characters. The virtual buffer emits attribute names verbatim into its XML markup (only spaces were sanitized, per #6249), producing attribute names likeIAccessible2::attribute_label"almenük"which are not well-formed XML. NVDA'sXMLFormatting.XMLTextParser(expat) then fails withExpatError: not well-formed (invalid token), so NVDA plays an error sound and cannot read any content in ranges containing the affected node.Minimal test case (from @jcsteh) — paste into the Firefox address bar:
Description of user facing changes:
Pages containing this kind of malformed markup are now read normally in browse mode; the broken attribute is ignored and the content is navigable, with no error sound.
Description of developer facing changes:
sanitizeXMLAttribNameinnvdaHelper/common/xml.hnow replaces every character outside a conservative allowlist (A-Z a-z 0-9 - . : _) with_, instead of only replacing spaces. A newisValidXMLNameCharhelper defines the allowlist. Names that would be empty or start with a character that is only valid in non-initial positions (digits,-,.) get a leading underscore, since XML's NameStartChar rules are stricter than NameChar.VBufStorage_fieldNode_t::addAttributeinnvdaHelper/vbufBase/storage.cppsanitizes names once at the attribute map's single insertion point, so the buffer only ever holds names that are valid in the XML markup it serializes to. A sanitized name never overwrites an existing attribute whose name was genuinely valid, and because the map's keys are the sanitized names, duplicate attributes (also rejected by expat) cannot be emitted.tests/unit/test_XMLFormatting.pypin the contract that every name the sanitizer can produce is accepted by the expat configuration NVDA parses buffer markup with.Description of development approach:
Sanitization is applied in
vbufBase, which is shared by all virtual buffer backends (Gecko, Chromium, MSHTML, Adobe), so no per-backend changes are needed. It happens once, when an attribute is added to the buffer (at page render), rather than on the serialization path that runs on every browse-mode text fetch. Names NVDA actually consumes are all valid already and pass through unchanged, so attribute matching (e.g.getAttributesString, find-by-attributes) is unaffected; only names that would previously have broken parsing entirely are altered.The allowlist is deliberately conservative ASCII rather than the full XML 1.0 fifth edition NameChar production: the expat build shipped with Python enforces the stricter fourth edition name rules and rejects many BMP characters the fifth edition allows (e.g. U+0132). Since every attribute name NVDA's Python code consumes is ASCII, replacing everything else loses nothing meaningful while guaranteeing the generated names are always parseable. The unit tests encode this as an executable contract, so a future change to Python's expat behaviour will be caught by CI.
An existing XML library was considered and rejected; see the review discussion below for the detailed survey.
Testing strategy:
tests/unit/test_XMLFormatting.py): every character the sanitizer can leave in a name is asserted to be accepted by expat in both interior and leading positions; the leading-character restrictions are asserted; the exact failing name from this issue (IAccessible2::attribute_label"almenük"→IAccessible2::attribute_label_almen_k_), the Broken Google Chrome support from NVDA #6249 Chrome case (fai clic→fai_clic), and the fourth-edition-only character U+0132 are covered; and markup containing sanitized names is parsed end to end throughXMLFormatting.XMLTextParser. Full unit suite passes (1392 tests).scons source; the affected C++ compiles cleanly (warnings as errors).runlintpasses.Known issues with pull request:
None known.
Code Review Checklist:
Notes on the checklist: a change log entry is included; no user/developer documentation or GUI help changes are needed. There is no unit test harness for the
vbufBaseC++ code itself, so the sanitizer's contract is instead pinned from the Python side:tests/unit/test_XMLFormatting.pymirrors the allowlist and asserts everything it permits is accepted by the expat parser NVDA uses, alongside end-to-end parsing cases; a manual test case is also provided. The fix applies to all virtual buffer backends and all output paths (speech and braille both consume the parsed buffer text). Add-on facing behaviour only changes for attribute names that previously broke parsing entirely.