CVE-2026-25896: One Dot That Unmasks Every XML Entity
fast-xml-parser is one of the most-downloaded XML libraries in the npm ecosystem - tens of millions of installs a week, a dependency of AWS SDKs, and the parser a lot of people reach for specifically because it's pure JavaScript with no C bindings to worry about. So when the thing that is supposed to neutralize markup can be talked into re-animating it, the blast radius is enormous. This is CVE-2026-25896, a critical (CVSS 9.3) entity-encoding bypass, and the entire bug is one unescaped character.
The thing that's supposed to save you
XML has five predefined entities: < > & " '. They exist so that text content can contain the characters that would otherwise be markup. When you parse Hello <b>World</b>, a correct parser gives you back the literal string Hello <b>World</b> as text - inert, never interpreted as an element. Every downstream consumer trusts that contract. If you later drop that string into a DOM, the <b> is content, not a tag.
fast-xml-parser also supports custom entities declared in a DOCTYPE:
<!DOCTYPE foo [ <!ENTITY company "Initech"> ]>
<root>&company;</root>
Parse that and &company; expands to Initech. Convenient. The implementation builds one regex per declared entity and runs a replace pass over the document.
The one line
Here is the code that turns a declared entity name into the matcher used for replacement, in DocTypeReader.js:
entities[entityName] = {
regx: RegExp(`&${entityName};`, "g"),
val: val
};
Read it slowly. entityName comes straight from attacker-controlled document text and is interpolated directly into a RegExp source string. No escaping. In a regular expression, a . is not a literal dot - it is the wildcard that matches any single character.
So declare an entity whose name is l. and the parser builds:
/&l.;/g
That pattern doesn't just match &l.;. It matches &l + any char + ; - which includes <. The custom-entity replacement pass runs over the already-decoded-and-re-encoded output, so it happily rewrites the built-in < and > entities that were protecting you. You didn't add an entity. You overwrote the alphabet.
Proof of concept
The whole exploit is nine lines of XML:
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY l. "<img src=x onerror=alert(1)>">
]>
<root>
<text>Hello <b>World</b></text>
</root>
The document body contains only encoded markup - <b> - which any auditor would sign off as safe. But &l.; matches <, and every < in the document is replaced with <img src=x onerror=alert(1)>. Parse it and result.root.text comes back as:
Hello <img src=x onerror=alert(1)>b>World<img src=x onerror=alert(1)>/b>
Live HTML, out of a field that contained none. Render that anywhere - a comment, a product feed, an SVG, a SOAP response echoed into a page - and the onerror fires. Stored XSS, delivered by the sanitizer.
Don't take my word for it. Type an entity name below and watch the generated regex reach across and swallow the built-in <. Then flip it to patched and watch the fix hold:
Going deeper: why the malicious entity wins
There's a subtlety worth spelling out, because it's what makes this reliable rather than flaky. A parsed document goes through two passes: the custom-entity replacement (the attacker's &l.; → payload) and the standard-entity decode (< → <). The bug only matters because the custom pass runs first and wins the race for the bytes <. If standard decoding ran first, < would already be < before the malicious regex ever saw it, and there'd be nothing to shadow. Order of operations is load-bearing in parsers, and here it's on the attacker's side.
And l. is only the friendliest payload. The wildcard is a primitive, not a one-off:
- Name
g.→/&g.;/gshadows>, so you control the closing delimiter too. - Name
..→/&..;/gmatches any&XY;- a single declaration to blanket every two-letter entity. - Because
RegExp()takes the name raw,*,+, and{n,}are in play as well - which turns this into a ReDoS primitive on the side (a{999}style names against a large document), a second, quieter denial-of-service bug living in the same unescaped line.
One missing escape didn't create one bug. It handed an attacker a regex-authoring gadget aimed at your entity table.
Why it rates 9.3
The vector is CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:H/A:N. Network-reachable, no privileges, no user interaction, scope-changed, high integrity impact. The CWE is CWE-185 - Incorrect Regular Expression, which is the honest classification: this is not really an "XML bug," it's a regex-injection bug that happens to live in an XML feature. The lesson generalizes to every codebase that builds a RegExp out of a string it didn't write.
The fix
Escape the metacharacters before you compile the pattern, so an entity name is matched literally:
const escaped = entityName.replace(/[.\*+?^${}()|\[\]\\]/g, '\\$&');
entities[entityName] = {
regx: RegExp(`&${escaped};`, "g"),
val: val
};
Now l. compiles to /&l\.;/g and matches only the literal string &l.; - which no legitimate document contains, and which can never collide with <. Fixed in v5.3.5 and v4.5.4. The v6 branch additionally blacklists . in validateEntityName.
If you depend on it: npm ls fast-xml-parser, then upgrade to >= 5.3.5 (or >= 4.5.4 on the v4 line). If you can't upgrade immediately and you don't need DOCTYPE entities, disable entity processing (processEntities: false).
Verify it yourself: NVD · GitHub Advisory GHSA-m7jm-9gc2-mpf2. Reported under coordinated disclosure; patched in twelve days.