CWE-91: XML Injection (aka Blind XPath Injection) - JavaScript
Overview
XML Injection in JavaScript/Node.js applications occurs when untrusted user input is used to construct XML documents or XPath expressions without validation or escaping. Injecting special characters (<, >, &, ', ") lets an attacker change the structure of a document rather than its content, or change what a query matches rather than what it looks for.
Primary defense: Build XML with an XML builder library such as xmlbuilder2 instead of template literals or string concatenation - these treat values passed to their content/text methods as text and escape them automatically. Where a builder isn't practical, escape every interpolated value with a dedicated encoder such as he.encode(). For XPath, do not concatenate: the xpath package supports variable binding through xpath.parse(expr).select({ node, variables }).
Common vulnerability scenarios: Express/Next.js endpoints that build XML responses or SOAP envelopes with template literals, XML configuration files written from user preferences, and XPath queries built by concatenating a search term into the expression.
Popular Node.js XML libraries:
- xmlbuilder2 - modern XML builder, recommended
- xml2js / fast-xml-parser / @xmldom/xmldom - XML parsing (not construction)
- xpath - XPath 1.0 evaluation over
@xmldom/xmldomdocuments, with variable binding - he - HTML/XML entity encoder, useful when a builder API is unavailable
- soap / strong-soap - SOAP clients with automatic envelope construction
Use @xmldom/xmldom, not the unscoped xmldom. The unscoped package has not been published since April 2021 (0.6.0) and npm audit reports seven unfixed advisories against it, one of which is this very weakness - "XML injection via unsafe CDATA serialization allows attacker-controlled markup insertion" (GHSA-wh4c-j3r5-mjhp), alongside node injection through comment, processing-instruction and DocumentType serialization. @xmldom/xmldom is the maintained fork, is a drop-in replacement for the parsing APIs used here, and is what the xpath package's own examples import.
Common Vulnerable Patterns
Template Literal Concatenation
// VULNERABLE - user input embedded directly in an XML template literal
function createUserXml(username, email) {
const xml = `<?xml version="1.0"?>
<user>
<username>${username}</username>
<email>${email}</email>
</user>`;
return xml;
}
// Attack: username = "</username><admin>true</admin><username>"
// Result: <username></username><admin>true</admin><username></username>
// Creates an unintended <admin> element
Why this is vulnerable: No XML special characters are escaped, so a value containing </username> closes the current element early and any markup that follows becomes part of the document structure instead of text content. The same flaw shows up wherever interpolated values land: SOAP envelopes (<UserId>${userId}</UserId> lets an attacker inject <Role>admin</Role>), XML config files, and attribute values - a value containing an unescaped " closes the attribute early and lets the attacker append new attributes. Parsing a string built this way (xml2js.parseString, new DOMParser().parseFromString(...)) doesn't retroactively escape it; the injection already happened before the parser ever ran. Libraries that convert plain JS objects to XML (e.g. jstoxml) can be just as vulnerable if a field's value already contains markup and the library doesn't escape by default.
XPath Query Injection
// VULNERABLE - user input concatenated into an XPath expression
const xpath = require('xpath');
const { DOMParser } = require('@xmldom/xmldom');
function findUserByName(xmlDoc, username) {
const doc = new DOMParser().parseFromString(xmlDoc, 'text/xml');
const query = `//user[name='${username}']`;
return xpath.select(query, doc);
}
// Attack: username = "' or '1'='1"
// XPath becomes: //user[name='' or '1'='1'] -> returns all users
Why this is vulnerable: This is a different sink from markup injection - the attacker isn't adding XML elements, they're changing the boolean logic of the query itself. Escaping XML entities does nothing here because the injection point is inside an XPath string literal, not XML markup.
Secure Patterns
xmlbuilder2 (Primary Defense)
// SECURE - xmlbuilder2 escapes element content automatically
const { create } = require('xmlbuilder2');
function validateUsername(username) {
return /^[a-zA-Z0-9._-]{1,100}$/.test(username);
}
function validateEmail(email) {
return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email);
}
function createUserXml(username, email) {
if (!validateUsername(username)) throw new Error('Invalid username');
if (!validateEmail(email)) throw new Error('Invalid email');
return create({ version: '1.0' })
.ele('user')
.ele('username').txt(username).up() // escaped automatically
.ele('email').txt(email).up()
.end({ prettyPrint: true });
}
// createUserXml("<script>alert('xss')</script>", "test@example.com")
// -> Error: Invalid username. validateUsername rejects the value before
// xmlbuilder2 sees it, so nothing is emitted at all.
//
// The escaping is the control that matters for a field with no such format
// constraint. Given a free-text bio element:
// create().ele('bio').txt('</bio><admin>true</admin>').end({ headless: true })
// -> <bio></bio><admin>true</admin></bio>
// The Express handler below requires this file as './validate-username'
module.exports = { validateUsername, validateEmail, createUserXml };
Why this works: .txt() treats its argument as character data, not markup, so it escapes for the context the value lands in - &, < and > in element content, and additionally " in an attribute set with .att(), which is what would otherwise close the attribute early. A value containing </username><admin>true</admin> becomes inert text rather than new elements. xmlbuilder2 maintains a DOM-like tree internally and serializes it, so there is never a raw string for an attacker to break out of. .ele(name) builds elements by name and .up() navigates back to the parent for fluent nesting. name is a name, not content, and nothing escapes it - though xmlbuilder2 does reject a malformed one, throwing InvalidCharacterError for .ele('evil><admin'). That check is narrower than it looks: a single string that is well-formed markup is parsed as markup rather than validated as a name, so .ele('<foo><bar/></foo>') inserts that subtree and throws nothing (measured on 4.0.3, and see Common Pitfalls). Validate keys anyway where they come from request data or a database: a name can be entirely valid XML and still be the wrong element to create. The regex validation on the values adds defense-in-depth by rejecting XML metacharacters before they reach the API.
Manual Escaping with he (Fallback)
// SECURE - explicit escaping when a builder API isn't practical
const he = require('he');
// useNamedReferences: false is he's default; passing it explicitly documents
// the requirement so nobody flips it on later.
const safeUsername = he.encode(username, { useNamedReferences: false });
const safeEmail = he.encode(email, { useNamedReferences: false });
const xml = `<user><username>${safeUsername}</username><email>${safeEmail}</email></user>`;
Why this works: he.encode() escapes XML/HTML entities, making it safe to interpolate the result into a template literal. useNamedReferences: false produces numeric character references - measured on he 1.2.0, <script>&"' encodes to <script>&"', which every XML parser resolves without a DTD. That is already the default; the hazard is turning it on, because he's named set is HTML5's, so a non-breaking space becomes and a strict XML parser with no DOCTYPE rejects the document as an undefined entity. This is more error-prone than xmlbuilder2 because every interpolated value needs its own explicit call; reach for it only when a tree-building API is unavailable (e.g. bundle-size-constrained frontend code).
SOAP with strong-soap
// SECURE - strong-soap builds the SOAP envelope, not the developer
const soap = require('strong-soap').soap;
async function callSoapServiceSecure(userId, action) {
if (!userId || typeof userId !== 'string' || userId.length > 50) {
throw new Error('Invalid userId');
}
const client = await soap.createClientAsync('https://api.example.com/service?wsdl');
return client.GetUserDataAsync({ UserId: userId, Action: action });
}
Why this works: strong-soap constructs the SOAP envelope from the WSDL definition and serializes the parameter object itself, escaping special characters automatically - even if userId contains </UserId><Role>admin</Role>, it's serialized as inert text. Handing the client a parameter object removes the hand-written envelope that made the vulnerable example exploitable. Pre-validating type and length still matters as defense-in-depth against oversized or malformed input. The unscoped soap package is an equally valid choice here and is not deprecated - it is actively published (1.10.0, July 2026) and builds envelopes the same way; pick either, and the security property is the same as long as neither one has you writing the envelope by hand.
XPath: Bind a Variable (Primary Defense)
// SECURE - the value is bound to $username, not interpolated into the expression
const xpath = require('xpath');
const FIND_USER = xpath.parse("//user[name=$username]"); // parsed once, no user input
function findUserByName(doc, username) {
return FIND_USER.select({ node: doc, variables: { username } });
}
Why this works: xpath.parse() compiles a fixed expression string that holds no attacker-controlled characters, and variables supplies $username as a value at evaluation time, so quotes, or and function calls inside it are compared as text rather than parsed as XPath syntax. Measured on xpath 0.0.34 against a document holding alice, bob and O'Brien: 1 node for alice, 0 for ' or '1'='1', where the interpolated version returns all three. It also accepts values the interpolated form cannot express at all - XPath 1.0 string literals have no escape sequence for their own delimiter, so O'Brien has no safe spelling inside a '...' literal.
Note the shape: the options object goes to the parsed expression's select, not to the top-level helper. xpath.select("//user[name=$username]", { node: doc, variables: {...} }) throws Context node does not appear to be a valid DOM node, because select's second argument is the node itself.
XPath: Iterate and Compare
// SECURE - no user input in the XPath expression itself
function findUserByNameSecure(doc, username) {
const users = xpath.select('//user', doc);
return users.find(user => {
const nameNode = xpath.select1('name', user);
return nameNode && nameNode.textContent === username;
});
}
Why this works: The XPath expression ('//user') is static, so there is nothing for user input to change; filtering happens afterward with an exact string comparison in JavaScript. Prefer variable binding above where the lookup is a predicate - this version materializes every <user> node and walks them in JS. Reach for it when the comparison itself has to happen in JavaScript: a locale-aware collation, a normalization step, or a canonicalized form the evaluator cannot express.
Framework-Specific Guidance
Express and Next.js
Validate query parameters before building the response, return a generic error body on failure, and set the content type explicitly:
const { create } = require('xmlbuilder2');
// The validator from the xmlbuilder2 section above, as its own module
const { validateUsername } = require('./validate-username');
app.get('/api/user', (req, res) => {
const { username } = req.query;
if (!validateUsername(username)) {
const errorXml = create({ version: '1.0' }).ele('error').txt('Invalid username').end();
return res.status(400).set('Content-Type', 'application/xml').send(errorXml);
}
const xml = create({ version: '1.0' }).ele('user').ele('username').txt(username).up().end();
res.set('Content-Type', 'application/xml').send(xml);
});
Why this works: Validating the raw query parameter before it reaches xmlbuilder2 is the earliest point in the request pipeline to reject malicious input. Setting Content-Type: application/xml explicitly prevents clients from misinterpreting the response as HTML, and a generic error message avoids leaking why validation failed.
Testing
- Submit
</username><admin>true</admin>to a field with no format constraint (a bio, a comment, a description). Assert on the serialized output, not the HTTP status: it must come back as</username>...text and re-parsing must yield one element, not two. A field guarded byvalidateUsernameis rejected before the escaping is exercised, so testing only that field proves nothing about the escaping. - Round-trip a value containing all five metacharacters (
<script>&"') through.txt(), serialize and re-parse, and assert the text comes back identical. That single assertion catches both an escaper that misses a character and a "fix" that double-escapes. - Submit
' or '1'='1to the XPath lookup and assert the result array has the same length as for a nonexistent user (zero), not the length of the whole document. The payload only bypasses a single-clause predicate: against//user[name=$n and password=$p]it has to appear in both fields, becauseandbinds tighter thanor. - Test normal inputs to confirm the secure pattern doesn't reject legitimate data, and include
O'Brien- a bound query matches it where an interpolated one cannot express it at all. - Test boundary cases: empty strings, maximum-length values, Unicode characters.
- Confirm attribute values and element names are both covered, not just element text content.
- Re-scan with the security scanner that produced the original finding to confirm it no longer triggers.
Common Pitfalls
- Passing untrusted input into
.ele(userInput)(an element name) instead of only.txt(userInput)(element content). xmlbuilder2 validates names rather than escaping them - measured on 4.0.3,.ele('a<b>c')and.att('a<b>c', 'v')both throwInvalidCharacterError: ... Invalid XML name: a<b>c- so what a name built from input buys an attacker is not smuggled markup but the choice of which perfectly valid element or attribute gets created. - Calling
.ele()with a single string argument taken from input: given one string, xmlbuilder2 parses it as markup rather than treating it as a name, so.ele('<foo><bar/></foo>')inserts that subtree. Unlike a malformed name, this one throws nothing, and a name-validation check written against the pitfall above does not cover it. - Setting
he.encode()'suseNamedReferences: truefor readability - the library then emits HTML5 named entities beyond XML's five predefined ones (a non-breaking space becomes ), and a strict XML parser with no DOCTYPE fails the document withEntity 'nbsp' not defined. The default,false, produces numeric references and is what you want. - Installing the unscoped
xmldomrather than@xmldom/xmldom- it has not been published since 2021 and carries seven unfixed advisories, one of them an XML injection through CDATA serialization, which is the weakness this page is about.
Dependencies and Installation
npm install xmlbuilder2- XML construction (primary defense).npm install he- entity encoding when a builder API is unavailable.npm install xpath @xmldom/xmldom- XPath evaluation over parsed documents. Not the unscopedxmldom; see the note in the Overview.npm install strong-soap(ornpm install soap) - SOAP client/server with built-in envelope escaping.