Skip to content

Xml: reject attributes that only exist because of ANTLR error recovery - #8562

Merged
timtebeek merged 6 commits into
mainfrom
tim/xml-malformed-attribute-recovery
Aug 26, 2026
Merged

timtebeek merged 6 commits into
mainfrom
tim/xml-malformed-attribute-recovery

Conversation

@timtebeek

@timtebeek timtebeek commented Aug 19, 2026 •

Copy link
Copy Markdown
Member

What's wrong

XMLParser.g4 applies attribute : Name '=' STRING to whatever the lexer produces. On malformed markup ANTLR error recovery reaches that rule with tokens that were never written, and XmlParserVisitor.visitAttribute built an Xml.Attribute from the result without checking. That goes wrong three different ways:

Input Recovery Result
<t k="" abc "v"/> inserts the missing = XmlPrinter re-emits it — the document reprints as <t k="" abc="v"/>
<t k="" a b "v"/> drops the rule's children c.EQUALS() is null → NullPointerException in convert
<t k="" x = 1 "v"/> resyncs past the 1 silently accepted as k="" plus an invented x="v"

The third is the one that matters. The dropped source survives in the neighbouring prefix, so the document reprints byte-for-byte and requirePrintEqualsInput never fires. The file is parsed, cached, and searched with an attribute value that is not what the source says.

Why it shows up in practice

The usual trigger is an embedded expression containing unescaped double quotes — common in generated or hand-written config that carries a scripting snippet in an attribute:

<set-variable name="allowedIps" value="@{
    string ips = "";
    ips += "10.0.0.1,";
    ips += "10.0.0.2";
    return ips;
}" />

STRING is '"' ~[<"]* '"', so this value is not representable — XML 1.0 §2.3 does not permit a literal " inside a "-delimited value, and xmllint rejects the file too. The lexer therefore re-tokenises the value into alternating STRING runs with stray tokens in the gaps, and the table above decides which symptom you get. Which one is essentially arbitrary: it depends only on the token shape of whatever sits inside the expression's string literals.

On main today, that document parses without error, reprints byte-for-byte, and yields:

[set-variable name=<allowedIps>]
[set-variable value=<@{
    string ips = >]

The value stops at the first quote — the addresses are not in the LST at all. A search recipe reading value finds nothing and reports a clean result. Worse, a recipe that writes the attribute writes the truncated value back: ChangeTagAttribute("set-variable", "value", "REDACTED") currently emits

<set-variable name="allowedIps" value="REDACTED"";
    ips += "10.0.0.1,";
    ips += "10.0.0.2";
    return ips;
}" />

which is no longer valid XML.

The fix

Require that an attribute is built only from tokens that actually appeared in the source, and that nothing but whitespace separates them:

  • each of Name, EQUALS, STRING must be present and not an ANTLR ErrorNode (recovery marks inserted tokens as error nodes, so this catches the invented = and the null dereference)
  • the attribute's prefix, beforeEquals, and the value's prefix must be whitespace only (this catches tokens that recovery dropped, which is what silently truncates the value)
  • the same whitespace requirement applies to the trailing prefix of each rule that accepts attributes — an element's beforeTagDelimiterPrefix, and the equivalents in visitXmldecl and visitJspdirective — for the case where recovery produces no attribute at all and the stray tokens are swallowed before the closing delimiter

Otherwise the parse fails with a message naming the actual problem:

Malformed attribute in policy.xml at line 3, column 9. The markup here is not a well-formed
name="value" attribute; common causes are a literal '"' inside a double-quoted value, which
XML 1.0 section 2.3 does not permit (write it as &quot; or delimit the value with single
quotes), an unquoted value, or an HTML-style attribute written without a value.

The separator check matches XML 1.0's S production (#x20 | #x9 | #xD | #xA) exactly, via
StringUtils.indexOfNonWhitespace, rather than StringUtils.isBlank — which also accepts \f, vertical tab
and non-ASCII Unicode spaces. Inside a tag the lexer skips only [ \t\r\n] (XMLLexer.g4, mode INSIDE),
so a separator holding any other "whitespace" is not whitespace the lexer consumed; it is a character it
failed to match and dropped. Both halves are needed, and neither alone is sufficient. The whitespace checks catch tokens recovery dropped — that is what silently truncates the value. The ErrorNode check catches tokens recovery inserted; there is nothing wrong with any prefix in <t k="" abc "v"/>, so a whitespace check cannot see it. Disabling either half locally lets two of the six regression cases through.

XmlPrinter.visitAttribute still appends '=' unconditionally. With this check in place no Xml.Attribute can reach it without a real = in the source, so it is no longer reachable as a bug; making the = optional in the LST would be a much larger change for no gain today.

Behaviour change

Documents matching the third row above previously parsed into an Xml.Document and now become a ParseError. That is the point of the change — they were being parsed into a tree that did not describe the source — but it does mean files that used to ingest "successfully" will start reporting. The first two rows already failed, just less legibly (is not print idempotent, or an NPE); they now carry an actionable message.

Well-formed spellings are unaffected and keep their full value, so there is a clear path for anyone hitting this:

<set-variable value='@{ string ips = ""; ips += "10.0.0.1,"; return ips; }'/>
<set-variable value="@{ string ips = &quot;10.0.0.1&quot;; return ips; }"/>

Notes on provenance

The invented = and the NullPointerException date to the original XML module and only became visible as parse failures once requirePrintEqualsInput landed in 8.1.14 (#3459).

The silent truncation is newer. Bisecting released versions (8.1.14 → 8.91.0-SNAPSHOT) puts it at 8.84.4: before that the same input failed loudly, after it the file is quietly accepted with a truncated value. The only rewrite-xml change in that range is #7906, which left-factored the element rule to support HTML void elements. The feature itself is correctly gated behind htmlMode, but the grammar restructuring it needed is not, so it changed ANTLR's error recovery for the attribute* loop in strict XML too. Worth keeping in mind for future grammar refactors — a change that looks purely structural can move which malformed inputs get rejected.

Testing

Added to XmlParserTest:

  • malformedAttributeIsNotSilentlyAccepted — six recovery shapes (including an orphan string in a tag and in an xmldecl) become a ParseError, still print verbatim, and carry the new message
  • attributeValueWithUnescapedQuotes — the embedded-expression document above, asserting the message
  • wellFormedEmbeddedExpressionsKeepTheirFullValue — &quot; and single-quoted delimiters keep the whole value

:rewrite-xml:test and :rewrite-maven:test pass. The pre-existing malformedBareAmpersandDoesNotThrow / malformedUnterminatedEndTagDoesNotThrow / malformedMissingRootCloseDoesNotThrow cases still pass, so lenient handling of other malformed input is unchanged.

On the separator character set specifically: isBlank and indexOfNonWhitespace differ only on
all-whitespace strings containing at least one of 21 characters (\f, vertical tab, and 19 non-ASCII
whitespace characters). No file under rewrite-xml/src/test or rewrite-maven/src/test contains any of
them, raw or as a Java escape, apart from the case added here — so no existing test input can take a
different path.

Two checks on the blast radius of the added strictness:

  • Parsing 12,409 XML and POM files from a local ~/.m2/repository (1,720) and Gradle module cache (10,689) with the patched parser produces zero parse errors, so the guards do not fire on ordinary well-formed XML.
  • Differentially probing the old and new visitor over shapes the guards newly reject — HTML boolean attributes, unquoted values, JSP dynamic attributes, <root a/> — shows every one of them was already a ParseError before this change. For those the PR replaces an NPE or an idempotency failure with a readable message. The only previously-successful shapes that now fail are genuinely malformed: <t a=="1"/>, and orphan strings inside a tag.

@github-project-automation github-project-automation Bot moved this to In Progress in OpenRewrite Aug 19, 2026
@timtebeek
timtebeek marked this pull request as ready for review August 19, 2026 19:52
@timtebeek timtebeek added bug Something isn't working xml parser labels Aug 24, 2026
@timtebeek timtebeek moved this from In Progress to Ready to Review in OpenRewrite Aug 24, 2026
/// Reject an attribute ANTLR only produced by recovering from malformed markup. Such a tree can still
/// reprint byte-for-byte — the dropped source survives in a prefix — so `requirePrintEqualsInput` misses it.
private void requireWellFormedAttribute(boolean wellFormed, ParserRuleContext ctx) {
if (!wellFormed) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are only checking if StringUtils.isBlank isn't there a more specific checks of well formed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good push — there is a more specific check available, and I've switched to it in 40e97b3.

To explain why it started as a blankness check: the four gaps being guarded are exactly XML 1.0's S production, so "holds only whitespace" is the well-formedness condition there rather than a proxy for it.

STag      ::= '<' Name (S Attribute)* S? '>'      -- attribute prefix, beforeTagDelimiterPrefix
Attribute ::= Name Eq AttValue
Eq        ::= S? '=' S?                           -- beforeEquals, value prefix
S         ::= (#x20 | #x9 | #xD | #xA)+

It is also the only check that can see lexer-level dropped characters. Every whitespace rule in the grammar is -> skip, and an unmatched character raises a lexer error and is consumed without ever producing a token — so nothing token-based (adjacency, child count, ErrorNode) can observe it. That is precisely the <t k="" x = 1 "v"/> case: 1 is not a NameStartChar, never enters the token stream, and silently relocates into a prefix. The isSourceToken half catches tokens recovery inserted; the whitespace half catches text it dropped. Neither alone is sufficient — disabling either lets cases through.

But you're right that the character set was wrong. StringUtils.isBlank also accepts \f, vertical tab, and non-ASCII Unicode spaces, while inside a tag the lexer skips only:

mode INSIDE;
S : [ \t\r\n] -> skip ;      // XMLLexer.g4:130

So a gap holding \f was never whitespace the lexer consumed — it is a character it failed to match and dropped, i.e. exactly what this guard exists to catch, waved through. Now using StringUtils.indexOfNonWhitespace, whose character set already matches that rule and the spec.

Differential before/after, all previously accepted:

<t k=""\fabc="v"/>      ACCEPTED  ->  REJECTED
<t k="" \fabc="v"/>     ACCEPTED  ->  REJECTED
<t\fk="v"/>             ACCEPTED  ->  REJECTED
<t k="v"\f/>            ACCEPTED  ->  REJECTED
<t k="v"\v/>            ACCEPTED  ->  REJECTED
<t k="v" />        ACCEPTED  ->  REJECTED
<t k=""abc="v"/>        ACCEPTED  ->  ACCEPTED   (deliberate, see below)

Two things I considered and deliberately left out:

Requiring a non-empty separator. XML's (S Attribute)* makes the S mandatory, so <a b="1"c="2"/> is malformed and still parses today. I left it: the LST is faithful and reprints byte-for-byte, so there is no corruption to prevent, and it is unrelated to this bug.

Failing on any ANTLR syntax error. More direct, but no parser in this repo does it — all six ANTLR modules forward to ctx.getOnError() and record nothing, so parseInputs cannot learn an error occurred. It would need a flag shared across the two listener instances, and it is far broader than these five positions: it would also reject the content-level and closing-tag recoveries XmlParserVisitor deliberately tolerates.

Blast radius unchanged: 12,409 XML and POM files from a local ~/.m2/repository and Gradle module cache still parse with zero errors under the stricter set.

`attribute : Name '=' STRING` is applied to whatever tokens the lexer
produces, and on malformed markup ANTLR recovery reaches that rule with
tokens that were never written. `XmlParserVisitor.visitAttribute` built an
`Xml.Attribute` from the result regardless, which went wrong three ways:

- a missing `=` was inserted, and `XmlPrinter` re-emitted it, so
  `x += "AAA,";` reprinted as `x += "AAA=,";`
- the rule's children were dropped entirely, so `c.EQUALS()` was null and
  `convert` threw a `NullPointerException`
- recovery resynced past a stray token, silently truncating the attribute
  value at the point the markup went wrong

The third is the worst: the dropped source survives in a neighbouring
prefix, so the document reprints byte-for-byte and `requirePrintEqualsInput`
does not catch it. The file is ingested with an attribute value that stops
at the first quote, and any recipe that edits the tag writes the truncated
value back and corrupts the file.

The usual trigger is an embedded expression containing unescaped double
quotes, e.g. `value="@{ string s = ""; s += "a,"; }"`. `STRING` is
`'"' ~[<"]* '"'`, so such a value is not representable — XML 1.0 section 2.3
does not permit a literal `"` in a `"`-delimited value — and the lexer
re-tokenises it into alternating `STRING` runs with stray tokens between.

Require that each attribute is built only from tokens that appeared in the
source, and that nothing but whitespace separates them, so these documents
fail with a message naming the real problem instead of being silently
mis-parsed. Well-formed spellings (`&quot;`, or a single-quoted delimiter)
are unaffected and keep their full value.

The invented `=` and the `NullPointerException` have been present since the
XML module was added; the silent truncation is newer, from the `element`
rule left-factoring in #7906, which changed error recovery for the
`attribute*` loop.
Extend the well-formedness check to the two other rules that accept
attributes, so stray tokens cannot hide in their trailing prefix, and
generalize the message now that the guard also covers unquoted and
value-less attributes.
The guards accepted any StringUtils.isBlank separator, which includes \f,
vertical tab and non-ASCII Unicode spaces. Inside a tag the lexer skips only
[ \t\r\n], so those characters are not whitespace the lexer consumed; they
are characters it failed to match and dropped, which is exactly what these
guards exist to catch.

Reuse StringUtils.indexOfNonWhitespace, whose character set already matches
the grammar's INSIDE-mode S rule and XML 1.0 section 2.3.
@timtebeek
timtebeek force-pushed the tim/xml-malformed-attribute-recovery branch from c363eab to 5055f0d Compare August 26, 2026 14:31
@timtebeek
timtebeek merged commit 0f38a2e into main Aug 26, 2026
1 check passed
@timtebeek
timtebeek deleted the tim/xml-malformed-attribute-recovery branch August 26, 2026 15:04
@github-project-automation github-project-automation Bot moved this from Ready to Review to Done in OpenRewrite Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working parser xml

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants