Skip to content

fix: don't crash on a closing tag with no matching opening tag - #861

Merged
amitguptagwl merged 1 commit into
NaturalIntelligence:masterfrom
hdimer:fix-unmatched-closing-tag-crash
Aug 15, 2026
Merged

fix: don't crash on a closing tag with no matching opening tag#861
amitguptagwl merged 1 commit into
NaturalIntelligence:masterfrom
hdimer:fix-unmatched-closing-tag-crash

Conversation

@hdimer

@hdimer hdimer commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Purpose / Goal

XMLParser.parse() crashes with an internal TypeError on XML where a closing tag has no matching opening tag. No issue was raised for this, so per the template here is input / expected / actual.

Input

const { XMLParser } = require("fast-xml-parser");
new XMLParser().parse("</a><b>text</b>");

Actual (v5.10.1)

TypeError: Cannot read properties of undefined (reading 'addChild')
    at OrderedObjParser.addChild (src/xmlparser/OrderedObjParser.js:611:17)
    at OrderedObjParser.parseXml (src/xmlparser/OrderedObjParser.js:586:18)

Expected

{ b: "text" } — the same leniency the parser already applies to every other kind of malformed input when validation is not requested:

input v5.10.1
parse("<a>") {"a":""}
parse("<a></b>") {"a":""}
parse("</a>") {}
parse("<a/>tail</b>") {"a":"","#text":"tail"}
parse("</a><b/>") TypeError

Cause

In the closing-tag branch, currentNode = this.tagsNodeStack.pop(). When the closing tag has no matching opening tag the stack is empty, pop() returns undefined, and the next node dereferences it. It only surfaces when something follows the stray closing tag, which is why the last two rows above disagree.

Fix

One line: fall back to the root node, so parsing continues at root scope.

currentNode = this.tagsNodeStack.pop() || xmlObj;

This also fixes three sibling crashes from the same undefined: </a><![CDATA[x]]> threw on reading 'tagname', </a><!--c--> on reading 'add', and </a><?pi?> on addChild.

I deliberately did not make this throw. parse() is lenient by design and validation is opt-in via parse(xml, true); validate("</rootNode>") already reports Closing tag 'rootNode' has not been opened. Throwing here would also be a breaking change, since </a>, <a></a></b> and <a/>tail</b> all reach this same empty-stack pop today and return normally.

Behaviour change

Only one, and it is the crash's sibling: text directly after a stray closing tag used to be silently dropped, and is now kept.

parse("</a>text</b>")   // before: {}    after: {"#text":"text"}

That matches parse("<a/>tail</b>"), which already returns {"a":"","#text":"tail"} on v5.10.1 through the same root-node path. It is pinned by a test.

To check the blast radius I ran 4,000 generated XML documents across 6 option sets (24,000 parses) against both builds: 24 rows went TypeError → parsed, 6 rows gained the root #text above, and nothing else changed. A wider sweep (11,110 token sequences × 10 option sets, including preserveOrder, captureMetaData, alwaysCreateTextNode, unpairedTags, stopNodes, transformTagName, updateTag) showed no structural output change at all.

Two things I noticed but left alone rather than widen this PR:

  • The && currentNode half of the captureMetaData guard just below is now unreachable, since the pop can no longer be falsy. Happy to remove it if you want.
  • There is a second unguarded tagsNodeStack.pop() in the unpaired-tag branch, but I could only reach it with a pathological config (unpairedTags: ["!xml"]), which fails identically before and after. Different user story.

Tests

Added to spec/xmlParser_spec.js, covering the crash, the root-scope fallback, the CDATA variant and the negative case above. Each assertion fails on unfixed master and passes with the fix. Full suite: 325 specs, 0 failures.

Perf

node benchmark/XmlParser.mjs, 3 interleaved rounds per build on the same machine (medians, requests/second):

before after
fxp 24,213 26,798
fxp - preserve order 30,192 30,277

Honestly: this machine was too noisy to resolve a difference. xml2js, which neither build touches, swung between 3,251 and 15,677 across the same runs, so treat the table as "no measurable change" rather than a win. The change adds one || on the closing-tag path and no allocation.

Type

  • Bug Fix
  • Refactoring / Technology upgrade
  • New Feature

Used AI assistance on this; I reviewed and tested the change myself.

A closing tag with no matching opening tag pops an empty tagsNodeStack, so
currentNode became undefined and the next node crashed the parser with
"TypeError: Cannot read properties of undefined (reading 'addChild')".

Fall back to the root node instead, so parsing continues at root scope. That
matches how the parser already handles the same input when nothing follows
the stray closing tag.
@hdimer
hdimer marked this pull request as ready for review August 14, 2026 23:51
@amitguptagwl
amitguptagwl merged commit c8e2f28 into NaturalIntelligence:master Aug 15, 2026
14 checks passed
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.

2 participants